diff options
Diffstat (limited to 'app')
48 files changed, 2608 insertions, 276 deletions
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9af373d..7888161 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -16,6 +16,7 @@ //= require bootstrap //= require rails.validations //= require rails.validations.simple_form +//= require typeahead.bundle //= require leap //= require platform //= require tickets diff --git a/app/assets/javascripts/leap.js b/app/assets/javascripts/leap.js index 94e602d..c8fbcf5 100644 --- a/app/assets/javascripts/leap.js +++ b/app/assets/javascripts/leap.js @@ -5,3 +5,5 @@ function alert_message(msg) { $('#messages').append('<div class="alert alert-error"><a class="close" data-dismiss="alert">×</a><span>'+msg+'</span></div>'); } + +ClientSideValidations.formBuilders['SimpleForm::FormBuilder'].wrappers.bootstrap = ClientSideValidations.formBuilders['SimpleForm::FormBuilder'].wrappers["default"]; diff --git a/app/assets/javascripts/typeahead.bundle.js b/app/assets/javascripts/typeahead.bundle.js new file mode 100644 index 0000000..bb0c8ae --- /dev/null +++ b/app/assets/javascripts/typeahead.bundle.js @@ -0,0 +1,2451 @@ +/*! + * typeahead.js 0.11.1 + * https://github.com/twitter/typeahead.js + * Copyright 2013-2015 Twitter, Inc. and other contributors; Licensed MIT + */ + +(function(root, factory) { + if (typeof define === "function" && define.amd) { + define("bloodhound", [ "jquery" ], function(a0) { + return root["Bloodhound"] = factory(a0); + }); + } else if (typeof exports === "object") { + module.exports = factory(require("jquery")); + } else { + root["Bloodhound"] = factory(jQuery); + } +})(this, function($) { + var _ = function() { + "use strict"; + return { + isMsie: function() { + return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; + }, + isBlankString: function(str) { + return !str || /^\s*$/.test(str); + }, + escapeRegExChars: function(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + }, + isString: function(obj) { + return typeof obj === "string"; + }, + isNumber: function(obj) { + return typeof obj === "number"; + }, + isArray: $.isArray, + isFunction: $.isFunction, + isObject: $.isPlainObject, + isUndefined: function(obj) { + return typeof obj === "undefined"; + }, + isElement: function(obj) { + return !!(obj && obj.nodeType === 1); + }, + isJQuery: function(obj) { + return obj instanceof $; + }, + toStr: function toStr(s) { + return _.isUndefined(s) || s === null ? "" : s + ""; + }, + bind: $.proxy, + each: function(collection, cb) { + $.each(collection, reverseArgs); + function reverseArgs(index, value) { + return cb(value, index); + } + }, + map: $.map, + filter: $.grep, + every: function(obj, test) { + var result = true; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (!(result = test.call(null, val, key, obj))) { + return false; + } + }); + return !!result; + }, + some: function(obj, test) { + var result = false; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (result = test.call(null, val, key, obj)) { + return false; + } + }); + return !!result; + }, + mixin: $.extend, + identity: function(x) { + return x; + }, + clone: function(obj) { + return $.extend(true, {}, obj); + }, + getIdGenerator: function() { + var counter = 0; + return function() { + return counter++; + }; + }, + templatify: function templatify(obj) { + return $.isFunction(obj) ? obj : template; + function template() { + return String(obj); + } + }, + defer: function(fn) { + setTimeout(fn, 0); + }, + debounce: function(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments, later, callNow; + later = function() { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; + }, + throttle: function(func, wait) { + var context, args, timeout, result, previous, later; + previous = 0; + later = function() { + previous = new Date(); + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date(), remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }, + stringify: function(val) { + return _.isString(val) ? val : JSON.stringify(val); + }, + noop: function() {} + }; + }(); + var VERSION = "0.11.1"; + var tokenizers = function() { + "use strict"; + return { + nonword: nonword, + whitespace: whitespace, + obj: { + nonword: getObjTokenizer(nonword), + whitespace: getObjTokenizer(whitespace) + } + }; + function whitespace(str) { + str = _.toStr(str); + return str ? str.split(/\s+/) : []; + } + function nonword(str) { + str = _.toStr(str); + return str ? str.split(/\W+/) : []; + } + function getObjTokenizer(tokenizer) { + return function setKey(keys) { + keys = _.isArray(keys) ? keys : [].slice.call(arguments, 0); + return function tokenize(o) { + var tokens = []; + _.each(keys, function(k) { + tokens = tokens.concat(tokenizer(_.toStr(o[k]))); + }); + return tokens; + }; + }; + } + }(); + var LruCache = function() { + "use strict"; + function LruCache(maxSize) { + this.maxSize = _.isNumber(maxSize) ? maxSize : 100; + this.reset(); + if (this.maxSize <= 0) { + this.set = this.get = $.noop; + } + } + _.mixin(LruCache.prototype, { + set: function set(key, val) { + var tailItem = this.list.tail, node; + if (this.size >= this.maxSize) { + this.list.remove(tailItem); + delete this.hash[tailItem.key]; + this.size--; + } + if (node = this.hash[key]) { + node.val = val; + this.list.moveToFront(node); + } else { + node = new Node(key, val); + this.list.add(node); + this.hash[key] = node; + this.size++; + } + }, + get: function get(key) { + var node = this.hash[key]; + if (node) { + this.list.moveToFront(node); + return node.val; + } + }, + reset: function reset() { + this.size = 0; + this.hash = {}; + this.list = new List(); + } + }); + function List() { + this.head = this.tail = null; + } + _.mixin(List.prototype, { + add: function add(node) { + if (this.head) { + node.next = this.head; + this.head.prev = node; + } + this.head = node; + this.tail = this.tail || node; + }, + remove: function remove(node) { + node.prev ? node.prev.next = node.next : this.head = node.next; + node.next ? node.next.prev = node.prev : this.tail = node.prev; + }, + moveToFront: function(node) { + this.remove(node); + this.add(node); + } + }); + function Node(key, val) { + this.key = key; + this.val = val; + this.prev = this.next = null; + } + return LruCache; + }(); + var PersistentStorage = function() { + "use strict"; + var LOCAL_STORAGE; + try { + LOCAL_STORAGE = window.localStorage; + LOCAL_STORAGE.setItem("~~~", "!"); + LOCAL_STORAGE.removeItem("~~~"); + } catch (err) { + LOCAL_STORAGE = null; + } + function PersistentStorage(namespace, override) { + this.prefix = [ "__", namespace, "__" ].join(""); + this.ttlKey = "__ttl__"; + this.keyMatcher = new RegExp("^" + _.escapeRegExChars(this.prefix)); + this.ls = override || LOCAL_STORAGE; + !this.ls && this._noop(); + } + _.mixin(PersistentStorage.prototype, { + _prefix: function(key) { + return this.prefix + key; + }, + _ttlKey: function(key) { + return this._prefix(key) + this.ttlKey; + }, + _noop: function() { + this.get = this.set = this.remove = this.clear = this.isExpired = _.noop; + }, + _safeSet: function(key, val) { + try { + this.ls.setItem(key, val); + } catch (err) { + if (err.name === "QuotaExceededError") { + this.clear(); + this._noop(); + } + } + }, + get: function(key) { + if (this.isExpired(key)) { + this.remove(key); + } + return decode(this.ls.getItem(this._prefix(key))); + }, + set: function(key, val, ttl) { + if (_.isNumber(ttl)) { + this._safeSet(this._ttlKey(key), encode(now() + ttl)); + } else { + this.ls.removeItem(this._ttlKey(key)); + } + return this._safeSet(this._prefix(key), encode(val)); + }, + remove: function(key) { + this.ls.removeItem(this._ttlKey(key)); + this.ls.removeItem(this._prefix(key)); + return this; + }, + clear: function() { + var i, keys = gatherMatchingKeys(this.keyMatcher); + for (i = keys.length; i--; ) { + this.remove(keys[i]); + } + return this; + }, + isExpired: function(key) { + var ttl = decode(this.ls.getItem(this._ttlKey(key))); + return _.isNumber(ttl) && now() > ttl ? true : false; + } + }); + return PersistentStorage; + function now() { + return new Date().getTime(); + } + function encode(val) { + return JSON.stringify(_.isUndefined(val) ? null : val); + } + function decode(val) { + return $.parseJSON(val); + } + function gatherMatchingKeys(keyMatcher) { + var i, key, keys = [], len = LOCAL_STORAGE.length; + for (i = 0; i < len; i++) { + if ((key = LOCAL_STORAGE.key(i)).match(keyMatcher)) { + keys.push(key.replace(keyMatcher, "")); + } + } + return keys; + } + }(); + var Transport = function() { + "use strict"; + var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, sharedCache = new LruCache(10); + function Transport(o) { + o = o || {}; + this.cancelled = false; + this.lastReq = null; + this._send = o.transport; + this._get = o.limiter ? o.limiter(this._get) : this._get; + this._cache = o.cache === false ? new LruCache(0) : sharedCache; + } + Transport.setMaxPendingRequests = function setMaxPendingRequests(num) { + maxPendingRequests = num; + }; + Transport.resetCache = function resetCache() { + sharedCache.reset(); + }; + _.mixin(Transport.prototype, { + _fingerprint: function fingerprint(o) { + o = o || {}; + return o.url + o.type + $.param(o.data || {}); + }, + _get: function(o, cb) { + var that = this, fingerprint, jqXhr; + fingerprint = this._fingerprint(o); + if (this.cancelled || fingerprint !== this.lastReq) { + return; + } + if (jqXhr = pendingRequests[fingerprint]) { + jqXhr.done(done).fail(fail); + } else if (pendingRequestsCount < maxPendingRequests) { + pendingRequestsCount++; + pendingRequests[fingerprint] = this._send(o).done(done).fail(fail).always(always); + } else { + this.onDeckRequestArgs = [].slice.call(arguments, 0); + } + function done(resp) { + cb(null, resp); + that._cache.set(fingerprint, resp); + } + function fail() { + cb(true); + } + function always() { + pendingRequestsCount--; + delete pendingRequests[fingerprint]; + if (that.onDeckRequestArgs) { + that._get.apply(that, that.onDeckRequestArgs); + that.onDeckRequestArgs = null; + } + } + }, + get: function(o, cb) { + var resp, fingerprint; + cb = cb || $.noop; + o = _.isString(o) ? { + url: o + } : o || {}; + fingerprint = this._fingerprint(o); + this.cancelled = false; + this.lastReq = fingerprint; + if (resp = this._cache.get(fingerprint)) { + cb(null, resp); + } else { + this._get(o, cb); + } + }, + cancel: function() { + this.cancelled = true; + } + }); + return Transport; + }(); + var SearchIndex = window.SearchIndex = function() { + "use strict"; + var CHILDREN = "c", IDS = "i"; + function SearchIndex(o) { + o = o || {}; + if (!o.datumTokenizer || !o.queryTokenizer) { + $.error("datumTokenizer and queryTokenizer are both required"); + } + this.identify = o.identify || _.stringify; + this.datumTokenizer = o.datumTokenizer; + this.queryTokenizer = o.queryTokenizer; + this.reset(); + } + _.mixin(SearchIndex.prototype, { + bootstrap: function bootstrap(o) { + this.datums = o.datums; + this.trie = o.trie; + }, + add: function(data) { + var that = this; + data = _.isArray(data) ? data : [ data ]; + _.each(data, function(datum) { + var id, tokens; + that.datums[id = that.identify(datum)] = datum; + tokens = normalizeTokens(that.datumTokenizer(datum)); + _.each(tokens, function(token) { + var node, chars, ch; + node = that.trie; + chars = token.split(""); + while (ch = chars.shift()) { + node = node[CHILDREN][ch] || (node[CHILDREN][ch] = newNode()); + node[IDS].push(id); + } + }); + }); + }, + get: function get(ids) { + var that = this; + return _.map(ids, function(id) { + return that.datums[id]; + }); + }, + search: function search(query) { + var that = this, tokens, matches; + tokens = normalizeTokens(this.queryTokenizer(query)); + _.each(tokens, function(token) { + var node, chars, ch, ids; + if (matches && matches.length === 0) { + return false; + } + node = that.trie; + chars = token.split(""); + while (node && (ch = chars.shift())) { + node = node[CHILDREN][ch]; + } + if (node && chars.length === 0) { + ids = node[IDS].slice(0); + matches = matches ? getIntersection(matches, ids) : ids; + } else { + matches = []; + return false; + } + }); + return matches ? _.map(unique(matches), function(id) { + return that.datums[id]; + }) : []; + }, + all: function all() { + var values = []; + for (var key in this.datums) { + values.push(this.datums[key]); + } + return values; + }, + reset: function reset() { + this.datums = {}; + this.trie = newNode(); + }, + serialize: function serialize() { + return { + datums: this.datums, + trie: this.trie + }; + } + }); + return SearchIndex; + function normalizeTokens(tokens) { + tokens = _.filter(tokens, function(token) { + return !!token; + }); + tokens = _.map(tokens, function(token) { + return token.toLowerCase(); + }); + return tokens; + } + function newNode() { + var node = {}; + node[IDS] = []; + node[CHILDREN] = {}; + return node; + } + function unique(array) { + var seen = {}, uniques = []; + for (var i = 0, len = array.length; i < len; i++) { + if (!seen[array[i]]) { + seen[array[i]] = true; + uniques.push(array[i]); + } + } + return uniques; + } + function getIntersection(arrayA, arrayB) { + var ai = 0, bi = 0, intersection = []; + arrayA = arrayA.sort(); + arrayB = arrayB.sort(); + var lenArrayA = arrayA.length, lenArrayB = arrayB.length; + while (ai < lenArrayA && bi < lenArrayB) { + if (arrayA[ai] < arrayB[bi]) { + ai++; + } else if (arrayA[ai] > arrayB[bi]) { + bi++; + } else { + intersection.push(arrayA[ai]); + ai++; + bi++; + } + } + return intersection; + } + }(); + var Prefetch = function() { + "use strict"; + var keys; + keys = { + data: "data", + protocol: "protocol", + thumbprint: "thumbprint" + }; + function Prefetch(o) { + this.url = o.url; + this.ttl = o.ttl; + this.cache = o.cache; + this.prepare = o.prepare; + this.transform = o.transform; + this.transport = o.transport; + this.thumbprint = o.thumbprint; + this.storage = new PersistentStorage(o.cacheKey); + } + _.mixin(Prefetch.prototype, { + _settings: function settings() { + return { + url: this.url, + type: "GET", + dataType: "json" + }; + }, + store: function store(data) { + if (!this.cache) { + return; + } + this.storage.set(keys.data, data, this.ttl); + this.storage.set(keys.protocol, location.protocol, this.ttl); + this.storage.set(keys.thumbprint, this.thumbprint, this.ttl); + }, + fromCache: function fromCache() { + var stored = {}, isExpired; + if (!this.cache) { + return null; + } + stored.data = this.storage.get(keys.data); + stored.protocol = this.storage.get(keys.protocol); + stored.thumbprint = this.storage.get(keys.thumbprint); + isExpired = stored.thumbprint !== this.thumbprint || stored.protocol !== location.protocol; + return stored.data && !isExpired ? stored.data : null; + }, + fromNetwork: function(cb) { + var that = this, settings; + if (!cb) { + return; + } + settings = this.prepare(this._settings()); + this.transport(settings).fail(onError).done(onResponse); + function onError() { + cb(true); + } + function onResponse(resp) { + cb(null, that.transform(resp)); + } + }, + clear: function clear() { + this.storage.clear(); + return this; + } + }); + return Prefetch; + }(); + var Remote = function() { + "use strict"; + function Remote(o) { + this.url = o.url; + this.prepare = o.prepare; + this.transform = o.transform; + this.transport = new Transport({ + cache: o.cache, + limiter: o.limiter, + transport: o.transport + }); + } + _.mixin(Remote.prototype, { + _settings: function settings() { + return { + url: this.url, + type: "GET", + dataType: "json" + }; + }, + get: function get(query, cb) { + var that = this, settings; + if (!cb) { + return; + } + query = query || ""; + settings = this.prepare(query, this._settings()); + return this.transport.get(settings, onResponse); + function onResponse(err, resp) { + err ? cb([]) : cb(that.transform(resp)); + } + }, + cancelLastRequest: function cancelLastRequest() { + this.transport.cancel(); + } + }); + return Remote; + }(); + var oParser = function() { + "use strict"; + return function parse(o) { + var defaults, sorter; + defaults = { + initialize: true, + identify: _.stringify, + datumTokenizer: null, + queryTokenizer: null, + sufficient: 5, + sorter: null, + local: [], + prefetch: null, + remote: null + }; + o = _.mixin(defaults, o || {}); + !o.datumTokenizer && $.error("datumTokenizer is required"); + !o.queryTokenizer && $.error("queryTokenizer is required"); + sorter = o.sorter; + o.sorter = sorter ? function(x) { + return x.sort(sorter); + } : _.identity; + o.local = _.isFunction(o.local) ? o.local() : o.local; + o.prefetch = parsePrefetch(o.prefetch); + o.remote = parseRemote(o.remote); + return o; + }; + function parsePrefetch(o) { + var defaults; + if (!o) { + return null; + } + defaults = { + url: null, + ttl: 24 * 60 * 60 * 1e3, + cache: true, + cacheKey: null, + thumbprint: "", + prepare: _.identity, + transform: _.identity, + transport: null + }; + o = _.isString(o) ? { + url: o + } : o; + o = _.mixin(defaults, o); + !o.url && $.error("prefetch requires url to be set"); + o.transform = o.filter || o.transform; + o.cacheKey = o.cacheKey || o.url; + o.thumbprint = VERSION + o.thumbprint; + o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; + return o; + } + function parseRemote(o) { + var defaults; + if (!o) { + return; + } + defaults = { + url: null, + cache: true, + prepare: null, + replace: null, + wildcard: null, + limiter: null, + rateLimitBy: "debounce", + rateLimitWait: 300, + transform: _.identity, + transport: null + }; + o = _.isString(o) ? { + url: o + } : o; + o = _.mixin(defaults, o); + !o.url && $.error("remote requires url to be set"); + o.transform = o.filter || o.transform; + o.prepare = toRemotePrepare(o); + o.limiter = toLimiter(o); + o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; + delete o.replace; + delete o.wildcard; + delete o.rateLimitBy; + delete o.rateLimitWait; + return o; + } + function toRemotePrepare(o) { + var prepare, replace, wildcard; + prepare = o.prepare; + replace = o.replace; + wildcard = o.wildcard; + if (prepare) { + return prepare; + } + if (replace) { + prepare = prepareByReplace; + } else if (o.wildcard) { + prepare = prepareByWildcard; + } else { + prepare = idenityPrepare; + } + return prepare; + function prepareByReplace(query, settings) { + settings.url = replace(settings.url, query); + return settings; + } + function prepareByWildcard(query, settings) { + settings.url = settings.url.replace(wildcard, encodeURIComponent(query)); + return settings; + } + function idenityPrepare(query, settings) { + return settings; + } + } + function toLimiter(o) { + var limiter, method, wait; + limiter = o.limiter; + method = o.rateLimitBy; + wait = o.rateLimitWait; + if (!limiter) { + limiter = /^throttle$/i.test(method) ? throttle(wait) : debounce(wait); + } + return limiter; + function debounce(wait) { + return function debounce(fn) { + return _.debounce(fn, wait); + }; + } + function throttle(wait) { + return function throttle(fn) { + return _.throttle(fn, wait); + }; + } + } + function callbackToDeferred(fn) { + return function wrapper(o) { + var deferred = $.Deferred(); + fn(o, onSuccess, onError); + return deferred; + function onSuccess(resp) { + _.defer(function() { + deferred.resolve(resp); + }); + } + function onError(err) { + _.defer(function() { + deferred.reject(err); + }); + } + }; + } + }(); + var Bloodhound = function() { + "use strict"; + var old; + old = window && window.Bloodhound; + function Bloodhound(o) { + o = oParser(o); + this.sorter = o.sorter; + this.identify = o.identify; + this.sufficient = o.sufficient; + this.local = o.local; + this.remote = o.remote ? new Remote(o.remote) : null; + this.prefetch = o.prefetch ? new Prefetch(o.prefetch) : null; + this.index = new SearchIndex({ + identify: this.identify, + datumTokenizer: o.datumTokenizer, + queryTokenizer: o.queryTokenizer + }); + o.initialize !== false && this.initialize(); + } + Bloodhound.noConflict = function noConflict() { + window && (window.Bloodhound = old); + return Bloodhound; + }; + Bloodhound.tokenizers = tokenizers; + _.mixin(Bloodhound.prototype, { + __ttAdapter: function ttAdapter() { + var that = this; + return this.remote ? withAsync : withoutAsync; + function withAsync(query, sync, async) { + return that.search(query, sync, async); + } + function withoutAsync(query, sync) { + return that.search(query, sync); + } + }, + _loadPrefetch: function loadPrefetch() { + var that = this, deferred, serialized; + deferred = $.Deferred(); + if (!this.prefetch) { + deferred.resolve(); + } else if (serialized = this.prefetch.fromCache()) { + this.index.bootstrap(serialized); + deferred.resolve(); + } else { + this.prefetch.fromNetwork(done); + } + return deferred.promise(); + function done(err, data) { + if (err) { + return deferred.reject(); + } + that.add(data); + that.prefetch.store(that.index.serialize()); + deferred.resolve(); + } + }, + _initialize: function initialize() { + var that = this, deferred; + this.clear(); + (this.initPromise = this._loadPrefetch()).done(addLocalToIndex); + return this.initPromise; + function addLocalToIndex() { + that.add(that.local); + } + }, + initialize: function initialize(force) { + return !this.initPromise || force ? this._initialize() : this.initPromise; + }, + add: function add(data) { + this.index.add(data); + return this; + }, + get: function get(ids) { + ids = _.isArray(ids) ? ids : [].slice.call(arguments); + return this.index.get(ids); + }, + search: function search(query, sync, async) { + var that = this, local; + local = this.sorter(this.index.search(query)); + sync(this.remote ? local.slice() : local); + if (this.remote && local.length < this.sufficient) { + this.remote.get(query, processRemote); + } else if (this.remote) { + this.remote.cancelLastRequest(); + } + return this; + function processRemote(remote) { + var nonDuplicates = []; + _.each(remote, function(r) { + !_.some(local, function(l) { + return that.identify(r) === that.identify(l); + }) && nonDuplicates.push(r); + }); + async && async(nonDuplicates); + } + }, + all: function all() { + return this.index.all(); + }, + clear: function clear() { + this.index.reset(); + return this; + }, + clearPrefetchCache: function clearPrefetchCache() { + this.prefetch && this.prefetch.clear(); + return this; + }, + clearRemoteCache: function clearRemoteCache() { + Transport.resetCache(); + return this; + }, + ttAdapter: function ttAdapter() { + return this.__ttAdapter(); + } + }); + return Bloodhound; + }(); + return Bloodhound; +}); + +(function(root, factory) { + if (typeof define === "function" && define.amd) { + define("typeahead.js", [ "jquery" ], function(a0) { + return factory(a0); + }); + } else if (typeof exports === "object") { + module.exports = factory(require("jquery")); + } else { + factory(jQuery); + } +})(this, function($) { + var _ = function() { + "use strict"; + return { + isMsie: function() { + return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; + }, + isBlankString: function(str) { + return !str || /^\s*$/.test(str); + }, + escapeRegExChars: function(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + }, + isString: function(obj) { + return typeof obj === "string"; + }, + isNumber: function(obj) { + return typeof obj === "number"; + }, + isArray: $.isArray, + isFunction: $.isFunction, + isObject: $.isPlainObject, + isUndefined: function(obj) { + return typeof obj === "undefined"; + }, + isElement: function(obj) { + return !!(obj && obj.nodeType === 1); + }, + isJQuery: function(obj) { + return obj instanceof $; + }, + toStr: function toStr(s) { + return _.isUndefined(s) || s === null ? "" : s + ""; + }, + bind: $.proxy, + each: function(collection, cb) { + $.each(collection, reverseArgs); + function reverseArgs(index, value) { + return cb(value, index); + } + }, + map: $.map, + filter: $.grep, + every: function(obj, test) { + var result = true; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (!(result = test.call(null, val, key, obj))) { + return false; + } + }); + return !!result; + }, + some: function(obj, test) { + var result = false; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (result = test.call(null, val, key, obj)) { + return false; + } + }); + return !!result; + }, + mixin: $.extend, + identity: function(x) { + return x; + }, + clone: function(obj) { + return $.extend(true, {}, obj); + }, + getIdGenerator: function() { + var counter = 0; + return function() { + return counter++; + }; + }, + templatify: function templatify(obj) { + return $.isFunction(obj) ? obj : template; + function template() { + return String(obj); + } + }, + defer: function(fn) { + setTimeout(fn, 0); + }, + debounce: function(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments, later, callNow; + later = function() { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; + }, + throttle: function(func, wait) { + var context, args, timeout, result, previous, later; + previous = 0; + later = function() { + previous = new Date(); + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date(), remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }, + stringify: function(val) { + return _.isString(val) ? val : JSON.stringify(val); + }, + noop: function() {} + }; + }(); + var WWW = function() { + "use strict"; + var defaultClassNames = { + wrapper: "twitter-typeahead", + input: "tt-input", + hint: "tt-hint", + menu: "tt-menu", + dataset: "tt-dataset", + suggestion: "tt-suggestion", + selectable: "tt-selectable", + empty: "tt-empty", + open: "tt-open", + cursor: "tt-cursor", + highlight: "tt-highlight" + }; + return build; + function build(o) { + var www, classes; + classes = _.mixin({}, defaultClassNames, o); + www = { + css: buildCss(), + classes: classes, + html: buildHtml(classes), + selectors: buildSelectors(classes) + }; + return { + css: www.css, + html: www.html, + classes: www.classes, + selectors: www.selectors, + mixin: function(o) { + _.mixin(o, www); + } + }; + } + function buildHtml(c) { + return { + wrapper: '<span class="' + c.wrapper + '"></span>', + menu: '<div class="' + c.menu + '"></div>' + }; + } + function buildSelectors(classes) { + var selectors = {}; + _.each(classes, function(v, k) { + selectors[k] = "." + v; + }); + return selectors; + } + function buildCss() { + var css = { + wrapper: { + position: "relative", + display: "inline-block" + }, + hint: { + position: "absolute", + top: "0", + left: "0", + borderColor: "transparent", + boxShadow: "none", + opacity: "1" + }, + input: { + position: "relative", + verticalAlign: "top", + backgroundColor: "transparent" + }, + inputWithNoHint: { + position: "relative", + verticalAlign: "top" + }, + menu: { + position: "absolute", + top: "100%", + left: "0", + zIndex: "100", + display: "none" + }, + ltr: { + left: "0", + right: "auto" + }, + rtl: { + left: "auto", + right: " 0" + } + }; + if (_.isMsie()) { + _.mixin(css.input, { + backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" + }); + } + return css; + } + }(); + var EventBus = function() { + "use strict"; + var namespace, deprecationMap; + namespace = "typeahead:"; + deprecationMap = { + render: "rendered", + cursorchange: "cursorchanged", + select: "selected", + autocomplete: "autocompleted" + }; + function EventBus(o) { + if (!o || !o.el) { + $.error("EventBus initialized without el"); + } + this.$el = $(o.el); + } + _.mixin(EventBus.prototype, { + _trigger: function(type, args) { + var $e; + $e = $.Event(namespace + type); + (args = args || []).unshift($e); + this.$el.trigger.apply(this.$el, args); + return $e; + }, + before: function(type) { + var args, $e; + args = [].slice.call(arguments, 1); + $e = this._trigger("before" + type, args); + return $e.isDefaultPrevented(); + }, + trigger: function(type) { + var deprecatedType; + this._trigger(type, [].slice.call(arguments, 1)); + if (deprecatedType = deprecationMap[type]) { + this._trigger(deprecatedType, [].slice.call(arguments, 1)); + } + } + }); + return EventBus; + }(); + var EventEmitter = function() { + "use strict"; + var splitter = /\s+/, nextTick = getNextTick(); + return { + onSync: onSync, + onAsync: onAsync, + off: off, + trigger: trigger + }; + function on(method, types, cb, context) { + var type; + if (!cb) { + return this; + } + types = types.split(splitter); + cb = context ? bindContext(cb, context) : cb; + this._callbacks = this._callbacks || {}; + while (type = types.shift()) { + this._callbacks[type] = this._callbacks[type] || { + sync: [], + async: [] + }; + this._callbacks[type][method].push(cb); + } + return this; + } + function onAsync(types, cb, context) { + return on.call(this, "async", types, cb, context); + } + function onSync(types, cb, context) { + return on.call(this, "sync", types, cb, context); + } + function off(types) { + var type; + if (!this._callbacks) { + return this; + } + types = types.split(splitter); + while (type = types.shift()) { + delete this._callbacks[type]; + } + return this; + } + function trigger(types) { + var type, callbacks, args, syncFlush, asyncFlush; + if (!this._callbacks) { + return this; + } + types = types.split(splitter); + args = [].slice.call(arguments, 1); + while ((type = types.shift()) && (callbacks = this._callbacks[type])) { + syncFlush = getFlush(callbacks.sync, this, [ type ].concat(args)); + asyncFlush = getFlush(callbacks.async, this, [ type ].concat(args)); + syncFlush() && nextTick(asyncFlush); + } + return this; + } + function getFlush(callbacks, context, args) { + return flush; + function flush() { + var cancelled; + for (var i = 0, len = callbacks.length; !cancelled && i < len; i += 1) { + cancelled = callbacks[i].apply(context, args) === false; + } + return !cancelled; + } + } + function getNextTick() { + var nextTickFn; + if (window.setImmediate) { + nextTickFn = function nextTickSetImmediate(fn) { + setImmediate(function() { + fn(); + }); + }; + } else { + nextTickFn = function nextTickSetTimeout(fn) { + setTimeout(function() { + fn(); + }, 0); + }; + } + return nextTickFn; + } + function bindContext(fn, context) { + return fn.bind ? fn.bind(context) : function() { + fn.apply(context, [].slice.call(arguments, 0)); + }; + } + }(); + var highlight = function(doc) { + "use strict"; + var defaults = { + node: null, + pattern: null, + tagName: "strong", + className: null, + wordsOnly: false, + caseSensitive: false + }; + return function hightlight(o) { + var regex; + o = _.mixin({}, defaults, o); + if (!o.node || !o.pattern) { + return; + } + o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ]; + regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly); + traverse(o.node, hightlightTextNode); + function hightlightTextNode(textNode) { + var match, patternNode, wrapperNode; + if (match = regex.exec(textNode.data)) { + wrapperNode = doc.createElement(o.tagName); + o.className && (wrapperNode.className = o.className); + patternNode = textNode.splitText(match.index); + patternNode.splitText(match[0].length); + wrapperNode.appendChild(patternNode.cloneNode(true)); + textNode.parentNode.replaceChild(wrapperNode, patternNode); + } + return !!match; + } + function traverse(el, hightlightTextNode) { + var childNode, TEXT_NODE_TYPE = 3; + for (var i = 0; i < el.childNodes.length; i++) { + childNode = el.childNodes[i]; + if (childNode.nodeType === TEXT_NODE_TYPE) { + i += hightlightTextNode(childNode) ? 1 : 0; + } else { + traverse(childNode, hightlightTextNode); + } + } + } + }; + function getRegex(patterns, caseSensitive, wordsOnly) { + var escapedPatterns = [], regexStr; + for (var i = 0, len = patterns.length; i < len; i++) { + escapedPatterns.push(_.escapeRegExChars(patterns[i])); + } + regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")"; + return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i"); + } + }(window.document); + var Input = function() { + "use strict"; + var specialKeyCodeMap; + specialKeyCodeMap = { + 9: "tab", + 27: "esc", + 37: "left", + 39: "right", + 13: "enter", + 38: "up", + 40: "down" + }; + function Input(o, www) { + o = o || {}; + if (!o.input) { + $.error("input is missing"); + } + www.mixin(this); + this.$hint = $(o.hint); + this.$input = $(o.input); + this.query = this.$input.val(); + this.queryWhenFocused = this.hasFocus() ? this.query : null; + this.$overflowHelper = buildOverflowHelper(this.$input); + this._checkLanguageDirection(); + if (this.$hint.length === 0) { + this.setHint = this.getHint = this.clearHint = this.clearHintIfInvalid = _.noop; + } + } + Input.normalizeQuery = function(str) { + return _.toStr(str).replace(/^\s*/g, "").replace(/\s{2,}/g, " "); + }; + _.mixin(Input.prototype, EventEmitter, { + _onBlur: function onBlur() { + this.resetInputValue(); + this.trigger("blurred"); + }, + _onFocus: function onFocus() { + this.queryWhenFocused = this.query; + this.trigger("focused"); + }, + _onKeydown: function onKeydown($e) { + var keyName = specialKeyCodeMap[$e.which || $e.keyCode]; + this._managePreventDefault(keyName, $e); + if (keyName && this._shouldTrigger(keyName, $e)) { + this.trigger(keyName + "Keyed", $e); + } + }, + _onInput: function onInput() { + this._setQuery(this.getInputValue()); + this.clearHintIfInvalid(); + this._checkLanguageDirection(); + }, + _managePreventDefault: function managePreventDefault(keyName, $e) { + var preventDefault; + switch (keyName) { + case "up": + case "down": + preventDefault = !withModifier($e); + break; + + default: + preventDefault = false; + } + preventDefault && $e.preventDefault(); + }, + _shouldTrigger: function shouldTrigger(keyName, $e) { + var trigger; + switch (keyName) { + case "tab": + trigger = !withModifier($e); + break; + + default: + trigger = true; + } + return trigger; + }, + _checkLanguageDirection: function checkLanguageDirection() { + var dir = (this.$input.css("direction") || "ltr").toLowerCase(); + if (this.dir !== dir) { + this.dir = dir; + this.$hint.attr("dir", dir); + this.trigger("langDirChanged", dir); + } + }, + _setQuery: function setQuery(val, silent) { + var areEquivalent, hasDifferentWhitespace; + areEquivalent = areQueriesEquivalent(val, this.query); + hasDifferentWhitespace = areEquivalent ? this.query.length !== val.length : false; + this.query = val; + if (!silent && !areEquivalent) { + this.trigger("queryChanged", this.query); + } else if (!silent && hasDifferentWhitespace) { + this.trigger("whitespaceChanged", this.query); + } + }, + bind: function() { + var that = this, onBlur, onFocus, onKeydown, onInput; + onBlur = _.bind(this._onBlur, this); + onFocus = _.bind(this._onFocus, this); + onKeydown = _.bind(this._onKeydown, this); + onInput = _.bind(this._onInput, this); + this.$input.on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); + if (!_.isMsie() || _.isMsie() > 9) { + this.$input.on("input.tt", onInput); + } else { + this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { + if (specialKeyCodeMap[$e.which || $e.keyCode]) { + return; + } + _.defer(_.bind(that._onInput, that, $e)); + }); + } + return this; + }, + focus: function focus() { + this.$input.focus(); + }, + blur: function blur() { + this.$input.blur(); + }, + getLangDir: function getLangDir() { + return this.dir; + }, + getQuery: function getQuery() { + return this.query || ""; + }, + setQuery: function setQuery(val, silent) { + this.setInputValue(val); + this._setQuery(val, silent); + }, + hasQueryChangedSinceLastFocus: function hasQueryChangedSinceLastFocus() { + return this.query !== this.queryWhenFocused; + }, + getInputValue: function getInputValue() { + return this.$input.val(); + }, + setInputValue: function setInputValue(value) { + this.$input.val(value); + this.clearHintIfInvalid(); + this._checkLanguageDirection(); + }, + resetInputValue: function resetInputValue() { + this.setInputValue(this.query); + }, + getHint: function getHint() { + return this.$hint.val(); + }, + setHint: function setHint(value) { + this.$hint.val(value); + }, + clearHint: function clearHint() { + this.setHint(""); + }, + clearHintIfInvalid: function clearHintIfInvalid() { + var val, hint, valIsPrefixOfHint, isValid; + val = this.getInputValue(); + hint = this.getHint(); + valIsPrefixOfHint = val !== hint && hint.indexOf(val) === 0; + isValid = val !== "" && valIsPrefixOfHint && !this.hasOverflow(); + !isValid && this.clearHint(); + }, + hasFocus: function hasFocus() { + return this.$input.is(":focus"); + }, + hasOverflow: function hasOverflow() { + var constraint = this.$input.width() - 2; + this.$overflowHelper.text(this.getInputValue()); + return this.$overflowHelper.width() >= constraint; + }, + isCursorAtEnd: function() { + var valueLength, selectionStart, range; + valueLength = this.$input.val().length; + selectionStart = this.$input[0].selectionStart; + if (_.isNumber(selectionStart)) { + return selectionStart === valueLength; + } else if (document.selection) { + range = document.selection.createRange(); + range.moveStart("character", -valueLength); + return valueLength === range.text.length; + } + return true; + }, + destroy: function destroy() { + this.$hint.off(".tt"); + this.$input.off(".tt"); + this.$overflowHelper.remove(); + this.$hint = this.$input = this.$overflowHelper = $("<div>"); + } + }); + return Input; + function buildOverflowHelper($input) { + return $('<pre aria-hidden="true"></pre>').css({ + position: "absolute", + visibility: "hidden", + whiteSpace: "pre", + fontFamily: $input.css("font-family"), + fontSize: $input.css("font-size"), + fontStyle: $input.css("font-style"), + fontVariant: $input.css("font-variant"), + fontWeight: $input.css("font-weight"), + wordSpacing: $input.css("word-spacing"), + letterSpacing: $input.css("letter-spacing"), + textIndent: $input.css("text-indent"), + textRendering: $input.css("text-rendering"), + textTransform: $input.css("text-transform") + }).insertAfter($input); + } + function areQueriesEquivalent(a, b) { + return Input.normalizeQuery(a) === Input.normalizeQuery(b); + } + function withModifier($e) { + return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey; + } + }(); + var Dataset = function() { + "use strict"; + var keys, nameGenerator; + keys = { + val: "tt-selectable-display", + obj: "tt-selectable-object" + }; + nameGenerator = _.getIdGenerator(); + function Dataset(o, www) { + o = o || {}; + o.templates = o.templates || {}; + o.templates.notFound = o.templates.notFound || o.templates.empty; + if (!o.source) { + $.error("missing source"); + } + if (!o.node) { + $.error("missing node"); + } + if (o.name && !isValidName(o.name)) { + $.error("invalid dataset name: " + o.name); + } + www.mixin(this); + this.highlight = !!o.highlight; + this.name = o.name || nameGenerator(); + this.limit = o.limit || 5; + this.displayFn = getDisplayFn(o.display || o.displayKey); + this.templates = getTemplates(o.templates, this.displayFn); + this.source = o.source.__ttAdapter ? o.source.__ttAdapter() : o.source; + this.async = _.isUndefined(o.async) ? this.source.length > 2 : !!o.async; + this._resetLastSuggestion(); + this.$el = $(o.node).addClass(this.classes.dataset).addClass(this.classes.dataset + "-" + this.name); + } + Dataset.extractData = function extractData(el) { + var $el = $(el); + if ($el.data(keys.obj)) { + return { + val: $el.data(keys.val) || "", + obj: $el.data(keys.obj) || null + }; + } + return null; + }; + _.mixin(Dataset.prototype, EventEmitter, { + _overwrite: function overwrite(query, suggestions) { + suggestions = suggestions || []; + if (suggestions.length) { + this._renderSuggestions(query, suggestions); + } else if (this.async && this.templates.pending) { + this._renderPending(query); + } else if (!this.async && this.templates.notFound) { + this._renderNotFound(query); + } else { + this._empty(); + } + this.trigger("rendered", this.name, suggestions, false); + }, + _append: function append(query, suggestions) { + suggestions = suggestions || []; + if (suggestions.length && this.$lastSuggestion.length) { + this._appendSuggestions(query, suggestions); + } else if (suggestions.length) { + this._renderSuggestions(query, suggestions); + } else if (!this.$lastSuggestion.length && this.templates.notFound) { + this._renderNotFound(query); + } + this.trigger("rendered", this.name, suggestions, true); + }, + _renderSuggestions: function renderSuggestions(query, suggestions) { + var $fragment; + $fragment = this._getSuggestionsFragment(query, suggestions); + this.$lastSuggestion = $fragment.children().last(); + this.$el.html($fragment).prepend(this._getHeader(query, suggestions)).append(this._getFooter(query, suggestions)); + }, + _appendSuggestions: function appendSuggestions(query, suggestions) { + var $fragment, $lastSuggestion; + $fragment = this._getSuggestionsFragment(query, suggestions); + $lastSuggestion = $fragment.children().last(); + this.$lastSuggestion.after($fragment); + this.$lastSuggestion = $lastSuggestion; + }, + _renderPending: function renderPending(query) { + var template = this.templates.pending; + this._resetLastSuggestion(); + template && this.$el.html(template({ + query: query, + dataset: this.name + })); + }, + _renderNotFound: function renderNotFound(query) { + var template = this.templates.notFound; + this._resetLastSuggestion(); + template && this.$el.html(template({ + query: query, + dataset: this.name + })); + }, + _empty: function empty() { + this.$el.empty(); + this._resetLastSuggestion(); + }, + _getSuggestionsFragment: function getSuggestionsFragment(query, suggestions) { + var that = this, fragment; + fragment = document.createDocumentFragment(); + _.each(suggestions, function getSuggestionNode(suggestion) { + var $el, context; + context = that._injectQuery(query, suggestion); + $el = $(that.templates.suggestion(context)).data(keys.obj, suggestion).data(keys.val, that.displayFn(suggestion)).addClass(that.classes.suggestion + " " + that.classes.selectable); + fragment.appendChild($el[0]); + }); + this.highlight && highlight({ + className: this.classes.highlight, + node: fragment, + pattern: query + }); + return $(fragment); + }, + _getFooter: function getFooter(query, suggestions) { + return this.templates.footer ? this.templates.footer({ + query: query, + suggestions: suggestions, + dataset: this.name + }) : null; + }, + _getHeader: function getHeader(query, suggestions) { + return this.templates.header ? this.templates.header({ + query: query, + suggestions: suggestions, + dataset: this.name + }) : null; + }, + _resetLastSuggestion: function resetLastSuggestion() { + this.$lastSuggestion = $(); + }, + _injectQuery: function injectQuery(query, obj) { + return _.isObject(obj) ? _.mixin({ + _query: query + }, obj) : obj; + }, + update: function update(query) { + var that = this, canceled = false, syncCalled = false, rendered = 0; + this.cancel(); + this.cancel = function cancel() { + canceled = true; + that.cancel = $.noop; + that.async && that.trigger("asyncCanceled", query); + }; + this.source(query, sync, async); + !syncCalled && sync([]); + function sync(suggestions) { + if (syncCalled) { + return; + } + syncCalled = true; + suggestions = (suggestions || []).slice(0, that.limit); + rendered = suggestions.length; + that._overwrite(query, suggestions); + if (rendered < that.limit && that.async) { + that.trigger("asyncRequested", query); + } + } + function async(suggestions) { + suggestions = suggestions || []; + if (!canceled && rendered < that.limit) { + that.cancel = $.noop; + rendered += suggestions.length; + that._append(query, suggestions.slice(0, that.limit - rendered)); + that.async && that.trigger("asyncReceived", query); + } + } + }, + cancel: $.noop, + clear: function clear() { + this._empty(); + this.cancel(); + this.trigger("cleared"); + }, + isEmpty: function isEmpty() { + return this.$el.is(":empty"); + }, + destroy: function destroy() { + this.$el = $("<div>"); + } + }); + return Dataset; + function getDisplayFn(display) { + display = display || _.stringify; + return _.isFunction(display) ? display : displayFn; + function displayFn(obj) { + return obj[display]; + } + } + function getTemplates(templates, displayFn) { + return { + notFound: templates.notFound && _.templatify(templates.notFound), + pending: templates.pending && _.templatify(templates.pending), + header: templates.header && _.templatify(templates.header), + footer: templates.footer && _.templatify(templates.footer), + suggestion: templates.suggestion || suggestionTemplate + }; + function suggestionTemplate(context) { + return $("<div>").text(displayFn(context)); + } + } + function isValidName(str) { + return /^[_a-zA-Z0-9-]+$/.test(str); + } + }(); + var Menu = function() { + "use strict"; + function Menu(o, www) { + var that = this; + o = o || {}; + if (!o.node) { + $.error("node is required"); + } + www.mixin(this); + this.$node = $(o.node); + this.query = null; + this.datasets = _.map(o.datasets, initializeDataset); + function initializeDataset(oDataset) { + var node = that.$node.find(oDataset.node).first(); + oDataset.node = node.length ? node : $("<div>").appendTo(that.$node); + return new Dataset(oDataset, www); + } + } + _.mixin(Menu.prototype, EventEmitter, { + _onSelectableClick: function onSelectableClick($e) { + this.trigger("selectableClicked", $($e.currentTarget)); + }, + _onRendered: function onRendered(type, dataset, suggestions, async) { + this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); + this.trigger("datasetRendered", dataset, suggestions, async); + }, + _onCleared: function onCleared() { + this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); + this.trigger("datasetCleared"); + }, + _propagate: function propagate() { + this.trigger.apply(this, arguments); + }, + _allDatasetsEmpty: function allDatasetsEmpty() { + return _.every(this.datasets, isDatasetEmpty); + function isDatasetEmpty(dataset) { + return dataset.isEmpty(); + } + }, + _getSelectables: function getSelectables() { + return this.$node.find(this.selectors.selectable); + }, + _removeCursor: function _removeCursor() { + var $selectable = this.getActiveSelectable(); + $selectable && $selectable.removeClass(this.classes.cursor); + }, + _ensureVisible: function ensureVisible($el) { + var elTop, elBottom, nodeScrollTop, nodeHeight; + elTop = $el.position().top; + elBottom = elTop + $el.outerHeight(true); + nodeScrollTop = this.$node.scrollTop(); + nodeHeight = this.$node.height() + parseInt(this.$node.css("paddingTop"), 10) + parseInt(this.$node.css("paddingBottom"), 10); + if (elTop < 0) { + this.$node.scrollTop(nodeScrollTop + elTop); + } else if (nodeHeight < elBottom) { + this.$node.scrollTop(nodeScrollTop + (elBottom - nodeHeight)); + } + }, + bind: function() { + var that = this, onSelectableClick; + onSelectableClick = _.bind(this._onSelectableClick, this); + this.$node.on("click.tt", this.selectors.selectable, onSelectableClick); + _.each(this.datasets, function(dataset) { + dataset.onSync("asyncRequested", that._propagate, that).onSync("asyncCanceled", that._propagate, that).onSync("asyncReceived", that._propagate, that).onSync("rendered", that._onRendered, that).onSync("cleared", that._onCleared, that); + }); + return this; + }, + isOpen: function isOpen() { + return this.$node.hasClass(this.classes.open); + }, + open: function open() { + this.$node.addClass(this.classes.open); + }, + close: function close() { + this.$node.removeClass(this.classes.open); + this._removeCursor(); + }, + setLanguageDirection: function setLanguageDirection(dir) { + this.$node.attr("dir", dir); + }, + selectableRelativeToCursor: function selectableRelativeToCursor(delta) { + var $selectables, $oldCursor, oldIndex, newIndex; + $oldCursor = this.getActiveSelectable(); + $selectables = this._getSelectables(); + oldIndex = $oldCursor ? $selectables.index($oldCursor) : -1; + newIndex = oldIndex + delta; + newIndex = (newIndex + 1) % ($selectables.length + 1) - 1; + newIndex = newIndex < -1 ? $selectables.length - 1 : newIndex; + return newIndex === -1 ? null : $selectables.eq(newIndex); + }, + setCursor: function setCursor($selectable) { + this._removeCursor(); + if ($selectable = $selectable && $selectable.first()) { + $selectable.addClass(this.classes.cursor); + this._ensureVisible($selectable); + } + }, + getSelectableData: function getSelectableData($el) { + return $el && $el.length ? Dataset.extractData($el) : null; + }, + getActiveSelectable: function getActiveSelectable() { + var $selectable = this._getSelectables().filter(this.selectors.cursor).first(); + return $selectable.length ? $selectable : null; + }, + getTopSelectable: function getTopSelectable() { + var $selectable = this._getSelectables().first(); + return $selectable.length ? $selectable : null; + }, + update: function update(query) { + var isValidUpdate = query !== this.query; + if (isValidUpdate) { + this.query = query; + _.each(this.datasets, updateDataset); + } + return isValidUpdate; + function updateDataset(dataset) { + dataset.update(query); + } + }, + empty: function empty() { + _.each(this.datasets, clearDataset); + this.query = null; + this.$node.addClass(this.classes.empty); + function clearDataset(dataset) { + dataset.clear(); + } + }, + destroy: function destroy() { + this.$node.off(".tt"); + this.$node = $("<div>"); + _.each(this.datasets, destroyDataset); + function destroyDataset(dataset) { + dataset.destroy(); + } + } + }); + return Menu; + }(); + var DefaultMenu = function() { + "use strict"; + var s = Menu.prototype; + function DefaultMenu() { + Menu.apply(this, [].slice.call(arguments, 0)); + } + _.mixin(DefaultMenu.prototype, Menu.prototype, { + open: function open() { + !this._allDatasetsEmpty() && this._show(); + return s.open.apply(this, [].slice.call(arguments, 0)); + }, + close: function close() { + this._hide(); + return s.close.apply(this, [].slice.call(arguments, 0)); + }, + _onRendered: function onRendered() { + if (this._allDatasetsEmpty()) { + this._hide(); + } else { + this.isOpen() && this._show(); + } + return s._onRendered.apply(this, [].slice.call(arguments, 0)); + }, + _onCleared: function onCleared() { + if (this._allDatasetsEmpty()) { + this._hide(); + } else { + this.isOpen() && this._show(); + } + return s._onCleared.apply(this, [].slice.call(arguments, 0)); + }, + setLanguageDirection: function setLanguageDirection(dir) { + this.$node.css(dir === "ltr" ? this.css.ltr : this.css.rtl); + return s.setLanguageDirection.apply(this, [].slice.call(arguments, 0)); + }, + _hide: function hide() { + this.$node.hide(); + }, + _show: function show() { + this.$node.css("display", "block"); + } + }); + return DefaultMenu; + }(); + var Typeahead = function() { + "use strict"; + function Typeahead(o, www) { + var onFocused, onBlurred, onEnterKeyed, onTabKeyed, onEscKeyed, onUpKeyed, onDownKeyed, onLeftKeyed, onRightKeyed, onQueryChanged, onWhitespaceChanged; + o = o || {}; + if (!o.input) { + $.error("missing input"); + } + if (!o.menu) { + $.error("missing menu"); + } + if (!o.eventBus) { + $.error("missing event bus"); + } + www.mixin(this); + this.eventBus = o.eventBus; + this.minLength = _.isNumber(o.minLength) ? o.minLength : 1; + this.input = o.input; + this.menu = o.menu; + this.enabled = true; + this.active = false; + this.input.hasFocus() && this.activate(); + this.dir = this.input.getLangDir(); + this._hacks(); + this.menu.bind().onSync("selectableClicked", this._onSelectableClicked, this).onSync("asyncRequested", this._onAsyncRequested, this).onSync("asyncCanceled", this._onAsyncCanceled, this).onSync("asyncReceived", this._onAsyncReceived, this).onSync("datasetRendered", this._onDatasetRendered, this).onSync("datasetCleared", this._onDatasetCleared, this); + onFocused = c(this, "activate", "open", "_onFocused"); + onBlurred = c(this, "deactivate", "_onBlurred"); + onEnterKeyed = c(this, "isActive", "isOpen", "_onEnterKeyed"); + onTabKeyed = c(this, "isActive", "isOpen", "_onTabKeyed"); + onEscKeyed = c(this, "isActive", "_onEscKeyed"); + onUpKeyed = c(this, "isActive", "open", "_onUpKeyed"); + onDownKeyed = c(this, "isActive", "open", "_onDownKeyed"); + onLeftKeyed = c(this, "isActive", "isOpen", "_onLeftKeyed"); + onRightKeyed = c(this, "isActive", "isOpen", "_onRightKeyed"); + onQueryChanged = c(this, "_openIfActive", "_onQueryChanged"); + onWhitespaceChanged = c(this, "_openIfActive", "_onWhitespaceChanged"); + this.input.bind().onSync("focused", onFocused, this).onSync("blurred", onBlurred, this).onSync("enterKeyed", onEnterKeyed, this).onSync("tabKeyed", onTabKeyed, this).onSync("escKeyed", onEscKeyed, this).onSync("upKeyed", onUpKeyed, this).onSync("downKeyed", onDownKeyed, this).onSync("leftKeyed", onLeftKeyed, this).onSync("rightKeyed", onRightKeyed, this).onSync("queryChanged", onQueryChanged, this).onSync("whitespaceChanged", onWhitespaceChanged, this).onSync("langDirChanged", this._onLangDirChanged, this); + } + _.mixin(Typeahead.prototype, { + _hacks: function hacks() { + var $input, $menu; + $input = this.input.$input || $("<div>"); + $menu = this.menu.$node || $("<div>"); + $input.on("blur.tt", function($e) { + var active, isActive, hasActive; + active = document.activeElement; + isActive = $menu.is(active); + hasActive = $menu.has(active).length > 0; + if (_.isMsie() && (isActive || hasActive)) { + $e.preventDefault(); + $e.stopImmediatePropagation(); + _.defer(function() { + $input.focus(); + }); + } + }); + $menu.on("mousedown.tt", function($e) { + $e.preventDefault(); + }); + }, + _onSelectableClicked: function onSelectableClicked(type, $el) { + this.select($el); + }, + _onDatasetCleared: function onDatasetCleared() { + this._updateHint(); + }, + _onDatasetRendered: function onDatasetRendered(type, dataset, suggestions, async) { + this._updateHint(); + this.eventBus.trigger("render", suggestions, async, dataset); + }, + _onAsyncRequested: function onAsyncRequested(type, dataset, query) { + this.eventBus.trigger("asyncrequest", query, dataset); + }, + _onAsyncCanceled: function onAsyncCanceled(type, dataset, query) { + this.eventBus.trigger("asynccancel", query, dataset); + }, + _onAsyncReceived: function onAsyncReceived(type, dataset, query) { + this.eventBus.trigger("asyncreceive", query, dataset); + }, + _onFocused: function onFocused() { + this._minLengthMet() && this.menu.update(this.input.getQuery()); + }, + _onBlurred: function onBlurred() { + if (this.input.hasQueryChangedSinceLastFocus()) { + this.eventBus.trigger("change", this.input.getQuery()); + } + }, + _onEnterKeyed: function onEnterKeyed(type, $e) { + var $selectable; + if ($selectable = this.menu.getActiveSelectable()) { + this.select($selectable) && $e.preventDefault(); + } + }, + _onTabKeyed: function onTabKeyed(type, $e) { + var $selectable; + if ($selectable = this.menu.getActiveSelectable()) { + this.select($selectable) && $e.preventDefault(); + } else if ($selectable = this.menu.getTopSelectable()) { + this.autocomplete($selectable) && $e.preventDefault(); + } + }, + _onEscKeyed: function onEscKeyed() { + this.close(); + }, + _onUpKeyed: function onUpKeyed() { + this.moveCursor(-1); + }, + _onDownKeyed: function onDownKeyed() { + this.moveCursor(+1); + }, + _onLeftKeyed: function onLeftKeyed() { + if (this.dir === "rtl" && this.input.isCursorAtEnd()) { + this.autocomplete(this.menu.getTopSelectable()); + } + }, + _onRightKeyed: function onRightKeyed() { + if (this.dir === "ltr" && this.input.isCursorAtEnd()) { + this.autocomplete(this.menu.getTopSelectable()); + } + }, + _onQueryChanged: function onQueryChanged(e, query) { + this._minLengthMet(query) ? this.menu.update(query) : this.menu.empty(); + }, + _onWhitespaceChanged: function onWhitespaceChanged() { + this._updateHint(); + }, + _onLangDirChanged: function onLangDirChanged(e, dir) { + if (this.dir !== dir) { + this.dir = dir; + this.menu.setLanguageDirection(dir); + } + }, + _openIfActive: function openIfActive() { + this.isActive() && this.open(); + }, + _minLengthMet: function minLengthMet(query) { + query = _.isString(query) ? query : this.input.getQuery() || ""; + return query.length >= this.minLength; + }, + _updateHint: function updateHint() { + var $selectable, data, val, query, escapedQuery, frontMatchRegEx, match; + $selectable = this.menu.getTopSelectable(); + data = this.menu.getSelectableData($selectable); + val = this.input.getInputValue(); + if (data && !_.isBlankString(val) && !this.input.hasOverflow()) { + query = Input.normalizeQuery(val); + escapedQuery = _.escapeRegExChars(query); + frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.+$)", "i"); + match = frontMatchRegEx.exec(data.val); + match && this.input.setHint(val + match[1]); + } else { + this.input.clearHint(); + } + }, + isEnabled: function isEnabled() { + return this.enabled; + }, + enable: function enable() { + this.enabled = true; + }, + disable: function disable() { + this.enabled = false; + }, + isActive: function isActive() { + return this.active; + }, + activate: function activate() { + if (this.isActive()) { + return true; + } else if (!this.isEnabled() || this.eventBus.before("active")) { + return false; + } else { + this.active = true; + this.eventBus.trigger("active"); + return true; + } + }, + deactivate: function deactivate() { + if (!this.isActive()) { + return true; + } else if (this.eventBus.before("idle")) { + return false; + } else { + this.active = false; + this.close(); + this.eventBus.trigger("idle"); + return true; + } + }, + isOpen: function isOpen() { + return this.menu.isOpen(); + }, + open: function open() { + if (!this.isOpen() && !this.eventBus.before("open")) { + this.menu.open(); + this._updateHint(); + this.eventBus.trigger("open"); + } + return this.isOpen(); + }, + close: function close() { + if (this.isOpen() && !this.eventBus.before("close")) { + this.menu.close(); + this.input.clearHint(); + this.input.resetInputValue(); + this.eventBus.trigger("close"); + } + return !this.isOpen(); + }, + setVal: function setVal(val) { + this.input.setQuery(_.toStr(val)); + }, + getVal: function getVal() { + return this.input.getQuery(); + }, + select: function select($selectable) { + var data = this.menu.getSelectableData($selectable); + if (data && !this.eventBus.before("select", data.obj)) { + this.input.setQuery(data.val, true); + this.eventBus.trigger("select", data.obj); + this.close(); + return true; + } + return false; + }, + autocomplete: function autocomplete($selectable) { + var query, data, isValid; + query = this.input.getQuery(); + data = this.menu.getSelectableData($selectable); + isValid = data && query !== data.val; + if (isValid && !this.eventBus.before("autocomplete", data.obj)) { + this.input.setQuery(data.val); + this.eventBus.trigger("autocomplete", data.obj); + return true; + } + return false; + }, + moveCursor: function moveCursor(delta) { + var query, $candidate, data, payload, cancelMove; + query = this.input.getQuery(); + $candidate = this.menu.selectableRelativeToCursor(delta); + data = this.menu.getSelectableData($candidate); + payload = data ? data.obj : null; + cancelMove = this._minLengthMet() && this.menu.update(query); + if (!cancelMove && !this.eventBus.before("cursorchange", payload)) { + this.menu.setCursor($candidate); + if (data) { + this.input.setInputValue(data.val); + } else { + this.input.resetInputValue(); + this._updateHint(); + } + this.eventBus.trigger("cursorchange", payload); + return true; + } + return false; + }, + destroy: function destroy() { + this.input.destroy(); + this.menu.destroy(); + } + }); + return Typeahead; + function c(ctx) { + var methods = [].slice.call(arguments, 1); + return function() { + var args = [].slice.call(arguments); + _.each(methods, function(method) { + return ctx[method].apply(ctx, args); + }); + }; + } + }(); + (function() { + "use strict"; + var old, keys, methods; + old = $.fn.typeahead; + keys = { + www: "tt-www", + attrs: "tt-attrs", + typeahead: "tt-typeahead" + }; + methods = { + initialize: function initialize(o, datasets) { + var www; + datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1); + o = o || {}; + www = WWW(o.classNames); + return this.each(attach); + function attach() { + var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu, eventBus, input, menu, typeahead, MenuConstructor; + _.each(datasets, function(d) { + d.highlight = !!o.highlight; + }); + $input = $(this); + $wrapper = $(www.html.wrapper); + $hint = $elOrNull(o.hint); + $menu = $elOrNull(o.menu); + defaultHint = o.hint !== false && !$hint; + defaultMenu = o.menu !== false && !$menu; + defaultHint && ($hint = buildHintFromInput($input, www)); + defaultMenu && ($menu = $(www.html.menu).css(www.css.menu)); + $hint && $hint.val(""); + $input = prepInput($input, www); + if (defaultHint || defaultMenu) { + $wrapper.css(www.css.wrapper); + $input.css(defaultHint ? www.css.input : www.css.inputWithNoHint); + $input.wrap($wrapper).parent().prepend(defaultHint ? $hint : null).append(defaultMenu ? $menu : null); + } + MenuConstructor = defaultMenu ? DefaultMenu : Menu; + eventBus = new EventBus({ + el: $input + }); + input = new Input({ + hint: $hint, + input: $input + }, www); + menu = new MenuConstructor({ + node: $menu, + datasets: datasets + }, www); + typeahead = new Typeahead({ + input: input, + menu: menu, + eventBus: eventBus, + minLength: o.minLength + }, www); + $input.data(keys.www, www); + $input.data(keys.typeahead, typeahead); + } + }, + isEnabled: function isEnabled() { + var enabled; + ttEach(this.first(), function(t) { + enabled = t.isEnabled(); + }); + return enabled; + }, + enable: function enable() { + ttEach(this, function(t) { + t.enable(); + }); + return this; + }, + disable: function disable() { + ttEach(this, function(t) { + t.disable(); + }); + return this; + }, + isActive: function isActive() { + var active; + ttEach(this.first(), function(t) { + active = t.isActive(); + }); + return active; + }, + activate: function activate() { + ttEach(this, function(t) { + t.activate(); + }); + return this; + }, + deactivate: function deactivate() { + ttEach(this, function(t) { + t.deactivate(); + }); + return this; + }, + isOpen: function isOpen() { + var open; + ttEach(this.first(), function(t) { + open = t.isOpen(); + }); + return open; + }, + open: function open() { + ttEach(this, function(t) { + t.open(); + }); + return this; + }, + close: function close() { + ttEach(this, function(t) { + t.close(); + }); + return this; + }, + select: function select(el) { + var success = false, $el = $(el); + ttEach(this.first(), function(t) { + success = t.select($el); + }); + return success; + }, + autocomplete: function autocomplete(el) { + var success = false, $el = $(el); + ttEach(this.first(), function(t) { + success = t.autocomplete($el); + }); + return success; + }, + moveCursor: function moveCursoe(delta) { + var success = false; + ttEach(this.first(), function(t) { + success = t.moveCursor(delta); + }); + return success; + }, + val: function val(newVal) { + var query; + if (!arguments.length) { + ttEach(this.first(), function(t) { + query = t.getVal(); + }); + return query; + } else { + ttEach(this, function(t) { + t.setVal(newVal); + }); + return this; + } + }, + destroy: function destroy() { + ttEach(this, function(typeahead, $input) { + revert($input); + typeahead.destroy(); + }); + return this; + } + }; + $.fn.typeahead = function(method) { + if (methods[method]) { + return methods[method].apply(this, [].slice.call(arguments, 1)); + } else { + return methods.initialize.apply(this, arguments); + } + }; + $.fn.typeahead.noConflict = function noConflict() { + $.fn.typeahead = old; + return this; + }; + function ttEach($els, fn) { + $els.each(function() { + var $input = $(this), typeahead; + (typeahead = $input.data(keys.typeahead)) && fn(typeahead, $input); + }); + } + function buildHintFromInput($input, www) { + return $input.clone().addClass(www.classes.hint).removeData().css(www.css.hint).css(getBackgroundStyles($input)).prop("readonly", true).removeAttr("id name placeholder required").attr({ + autocomplete: "off", + spellcheck: "false", + tabindex: -1 + }); + } + function prepInput($input, www) { + $input.data(keys.attrs, { + dir: $input.attr("dir"), + autocomplete: $input.attr("autocomplete"), + spellcheck: $input.attr("spellcheck"), + style: $input.attr("style") + }); + $input.addClass(www.classes.input).attr({ + autocomplete: "off", + spellcheck: false + }); + try { + !$input.attr("dir") && $input.attr("dir", "auto"); + } catch (e) {} + return $input; + } + function getBackgroundStyles($el) { + return { + backgroundAttachment: $el.css("background-attachment"), + backgroundClip: $el.css("background-clip"), + backgroundColor: $el.css("background-color"), + backgroundImage: $el.css("background-image"), + backgroundOrigin: $el.css("background-origin"), + backgroundPosition: $el.css("background-position"), + backgroundRepeat: $el.css("background-repeat"), + backgroundSize: $el.css("background-size") + }; + } + function revert($input) { + var www, $wrapper; + www = $input.data(keys.www); + $wrapper = $input.parent().filter(www.selectors.wrapper); + _.each($input.data(keys.attrs), function(val, key) { + _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); + }); + $input.removeData(keys.typeahead).removeData(keys.www).removeData(keys.attr).removeClass(www.classes.input); + if ($wrapper.length) { + $input.detach().insertAfter($wrapper); + $wrapper.remove(); + } + } + function $elOrNull(obj) { + var isValid, $el; + isValid = _.isJQuery(obj) || _.isElement(obj); + $el = isValid ? $(obj).first() : []; + return $el.length ? $el : null; + } + })(); +});
\ No newline at end of file diff --git a/app/assets/javascripts/users.js b/app/assets/javascripts/users.js index 70d78fd..3dae526 100644 --- a/app/assets/javascripts/users.js +++ b/app/assets/javascripts/users.js @@ -6,7 +6,7 @@ "error_tag":"span", "wrapper_error_class":"error", "wrapper_tag":"div", - "wrapper_class":"control-group" + "wrapper_class":"form-group" } // diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 9cd3a55..856a559 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,10 +9,8 @@ // // import bootstrap. // +@import "bootstrap-sprockets"; @import "bootstrap"; -@import "bootstrap-responsive"; -// backport bootstrap 3.2 features -@import "backport"; // // LEAP web app specific overrides diff --git a/app/assets/stylesheets/backport.scss b/app/assets/stylesheets/backport.scss deleted file mode 100644 index cadb035..0000000 --- a/app/assets/stylesheets/backport.scss +++ /dev/null @@ -1,24 +0,0 @@ -// -// Backporting styles from bootstrap 3.2 -// - - -// List options - -// Unstyled keeps list items block level, just removes default browser padding and list-style -.list-unstyled { - padding-left: 0; - list-style: none; -} - -// Inline turns list items into inline-block -.list-inline { - @extend .list-unstyled; - margin-left: -5px; - - > li { - display: inline-block; - padding-left: 5px; - padding-right: 5px; - } -} diff --git a/app/assets/stylesheets/leap.scss b/app/assets/stylesheets/leap.scss index abbfc88..8c4d702 100644 --- a/app/assets/stylesheets/leap.scss +++ b/app/assets/stylesheets/leap.scss @@ -57,7 +57,6 @@ [class*="-icon-"] { display: inline-block; - @include ie7-restore-right-whitespace(); vertical-align: middle; background-repeat: no-repeat; margin-top: 1px; @@ -92,8 +91,8 @@ // input.large { - font-size: $baseFontSize * 1.25; - line-height: $baseLineHeight * 1.5; + font-size: $font-size-base * 1.25; + line-height: $line-height-base * 1.5; } .p { @@ -120,9 +119,9 @@ input, textarea { // like a label, but with no background .label-clear { - background-color: $white; + background-color: $body-bg; text-shadow: none; - color: $black; + color: $gray-base; } // force a black icon, even if bootstrap thinks differently @@ -188,7 +187,7 @@ input, textarea { font-weight: bold; } a { - color: $textColor; + color: $text-color; } } @@ -199,7 +198,7 @@ input, textarea { } .download { a.btn { - width: 15em; + width: 18em; font-weight: bold; small { font-weight: normal; @@ -228,9 +227,9 @@ input, textarea { box-shadow: 0 2px 4px rgba(0,0,0,.1); li.active { a, a:hover { - background-color: $linkColor; - color: $white; - border-color: darken($linkColor, 0%); + background-color: $link-color; + color: $body-bg; + border-color: darken($link-color, 0%); cursor: pointer; } } @@ -265,7 +264,7 @@ $footer-height: 100px; $footer-border-width: 1px; $footer-gutter: 20px; // vertical gap above footer $footer-combined: $footer-height + $footer-border-width + $footer-gutter; -$footer-color: $grayLighter !default; +$footer-color: $gray-lighter !default; html, body { height: 100%; @@ -313,7 +312,7 @@ html, body { // border-top: $footer-border-width solid darken($footer-color, 10%); background-color: $footer-color; a { - color: $black; + color: $gray-base; margin: 0 5px; } } diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb new file mode 100644 index 0000000..ee7cca4 --- /dev/null +++ b/app/controllers/account_controller.rb @@ -0,0 +1,17 @@ +class AccountController < ApplicationController + + before_filter :require_registration_allowed + before_filter :redirect_if_logged_in + + def new + @user = User.new + end + + protected + + def require_registration_allowed + unless APP_CONFIG[:allow_registration] + redirect_to home_path + end + end +end diff --git a/app/controllers/v1/certs_controller.rb b/app/controllers/api/certs_controller.rb index ffa6e35..46a84d3 100644 --- a/app/controllers/v1/certs_controller.rb +++ b/app/controllers/api/certs_controller.rb @@ -1,4 +1,4 @@ -class V1::CertsController < ApiController +class Api::CertsController < ApiController before_filter :require_login, :unless => :anonymous_access_allowed? before_filter :require_enabled diff --git a/app/controllers/v1/configs_controller.rb b/app/controllers/api/configs_controller.rb index 4a6f455..0f9b8a6 100644 --- a/app/controllers/v1/configs_controller.rb +++ b/app/controllers/api/configs_controller.rb @@ -1,17 +1,15 @@ -class V1::ConfigsController < ApiController +class Api::ConfigsController < ApiController include ControllerExtension::JsonFile before_filter :require_login, :unless => :anonymous_access_allowed? before_filter :sanitize_id, only: :show - before_filter :lookup_file, only: :show - before_filter :fetch_file, only: :show def index render json: {services: service_paths} end def show - send_file + send_file lookup_file end protected @@ -23,7 +21,11 @@ class V1::ConfigsController < ApiController } def service_paths - Hash[SERVICE_IDS.map{|k,v| [k,"/1/configs/#{v}.json"] } ] + Hash[SERVICE_IDS.map{|k,v| [k,"/#{api_version}/configs/#{v}.json"] } ] + end + + def api_version + ["1", "2"].include?(params[:version]) ? params[:version] : "2" end def sanitize_id @@ -34,6 +36,6 @@ class V1::ConfigsController < ApiController def lookup_file path = APP_CONFIG[:config_file_paths][@id] not_found if path.blank? - @filename = Rails.root.join path + Rails.root.join path end end diff --git a/app/controllers/v1/identities_controller.rb b/app/controllers/api/identities_controller.rb index 4efd1f5..ab2ac00 100644 --- a/app/controllers/v1/identities_controller.rb +++ b/app/controllers/api/identities_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class IdentitiesController < ApiController before_filter :token_authenticate before_filter :require_monitor diff --git a/app/controllers/v1/messages_controller.rb b/app/controllers/api/messages_controller.rb index c0ca0c7..a69a40a 100644 --- a/app/controllers/v1/messages_controller.rb +++ b/app/controllers/api/messages_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class MessagesController < ApiController before_filter :require_login diff --git a/app/controllers/v1/services_controller.rb b/app/controllers/api/services_controller.rb index 523eb44..da2774b 100644 --- a/app/controllers/v1/services_controller.rb +++ b/app/controllers/api/services_controller.rb @@ -1,4 +1,4 @@ -class V1::ServicesController < ApiController +class Api::ServicesController < ApiController before_filter :require_login, :unless => :anonymous_access_allowed? diff --git a/app/controllers/v1/sessions_controller.rb b/app/controllers/api/sessions_controller.rb index a343d9b..c8deb7a 100644 --- a/app/controllers/v1/sessions_controller.rb +++ b/app/controllers/api/sessions_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class SessionsController < ApiController before_filter :require_login, only: :destroy diff --git a/app/controllers/v1/smtp_certs_controller.rb b/app/controllers/api/smtp_certs_controller.rb index 5760645..d9eab7d 100644 --- a/app/controllers/v1/smtp_certs_controller.rb +++ b/app/controllers/api/smtp_certs_controller.rb @@ -1,4 +1,4 @@ -class V1::SmtpCertsController < ApiController +class Api::SmtpCertsController < ApiController before_filter :require_login before_filter :require_email_account diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/api/users_controller.rb index 6640d10..c79a729 100644 --- a/app/controllers/v1/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class UsersController < ApiController include ControllerExtension::FetchUser @@ -50,8 +50,7 @@ module V1 end def destroy - destroy_identity = current_user.is_monitor? || params[:identities] == "destroy" - @user.account.destroy(destroy_identity) + @user.account.destroy(release_handles) if @user == current_user logout end @@ -60,6 +59,10 @@ module V1 private + def release_handles + current_user.is_monitor? || params[:identities] == "destroy" + end + # tester auth can only create test users. def create_test_account if User::is_test?(params[:user][:login]) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 079dc18..2af2f29 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base before_filter :no_frame_header before_filter :language_header rescue_from StandardError, :with => :default_error_handler - rescue_from RestClient::Exception, :with => :default_error_handler + rescue_from CouchRest::Exception, :with => :default_error_handler ActiveSupport.run_load_hooks(:application_controller, self) diff --git a/app/controllers/controller_extension/fetch_user.rb b/app/controllers/controller_extension/fetch_user.rb index 97f92fa..632291d 100644 --- a/app/controllers/controller_extension/fetch_user.rb +++ b/app/controllers/controller_extension/fetch_user.rb @@ -22,7 +22,7 @@ module ControllerExtension::FetchUser @user = User.find(params[:user_id] || params[:id]) if current_user.is_admin? || current_user.is_monitor? if @user.nil? - not_found(t(:no_such_thing, :thing => 'user'), users_url) + not_found(t(:no_such_user), users_url) elsif current_user.is_monitor? access_denied unless @user.is_test? end diff --git a/app/controllers/controller_extension/json_file.rb b/app/controllers/controller_extension/json_file.rb index 6be919a..df9cf55 100644 --- a/app/controllers/controller_extension/json_file.rb +++ b/app/controllers/controller_extension/json_file.rb @@ -4,20 +4,25 @@ module ControllerExtension::JsonFile protected - def send_file - if stale?(:last_modified => @file.mtime) - response.content_type = 'application/json' - render :text => @file.read + def send_file(filename) + file = fetch_file(filename) + if file.present? + send_file_or_cache_hit(file) + else + not_found end end - def fetch_file - if File.exists?(@filename) - @file = File.new(@filename) - else - not_found + def send_file_or_cache_hit(file) + if stale?(:last_modified => file.mtime) + response.content_type = 'application/json' + render :text => file.read end end + def fetch_file(filename) + File.new(filename) if File.exist?(filename) + end + end diff --git a/app/controllers/static_config_controller.rb b/app/controllers/static_config_controller.rb index c78e006..46e7cd2 100644 --- a/app/controllers/static_config_controller.rb +++ b/app/controllers/static_config_controller.rb @@ -5,13 +5,9 @@ class StaticConfigController < ActionController::Base include ControllerExtension::JsonFile before_filter :set_minimum_client_version - before_filter :set_filename - before_filter :fetch_file - - PROVIDER_JSON = Rails.root.join('config', 'provider', 'provider.json') def provider - send_file + send_file provider_json end protected @@ -23,7 +19,8 @@ class StaticConfigController < ActionController::Base APP_CONFIG[:minimum_client_version].to_s end - def set_filename - @filename = PROVIDER_JSON + def provider_json + Rails.root.join APP_CONFIG[:config_file_paths]['provider'] end + end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1404b0e..4d198b9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,11 +5,9 @@ class UsersController < ApplicationController include ControllerExtension::FetchUser - before_filter :require_login, :except => [:new] - before_filter :redirect_if_logged_in, :only => [:new] + before_filter :require_login before_filter :require_admin, :only => [:index, :deactivate, :enable] - before_filter :fetch_user, :only => [:show, :edit, :update, :destroy, :deactivate, :enable] - before_filter :require_registration_allowed, only: :new + before_filter :fetch_user, :only => [:show, :edit, :destroy, :deactivate, :enable] respond_to :html @@ -27,25 +25,12 @@ class UsersController < ApplicationController @users = @users.limit(100) end - def new - @user = User.new - end - def show end def edit end - ## added so updating service level works, but not sure we will actually want this. also not sure that this is place to prevent user from updating own effective service level, but here as placeholder: - def update - @user.update_attributes(params[:user]) unless (!admin? and params[:user][:effective_service_level]) - if @user.valid? - flash[:notice] = I18n.t(:changes_saved) - end - respond_with @user, :location => edit_user_path(@user) - end - def deactivate @user.account.disable flash[:notice] = I18n.t("actions.user_disabled_message", username: @user.username) @@ -73,10 +58,11 @@ class UsersController < ApplicationController protected - def require_registration_allowed - unless APP_CONFIG[:allow_registration] - redirect_to home_path + def user_params + if admin? + params.require(:user).permit(:effective_service_level) + else + params.require(:user).permit(:password, :password_confirmation) end end - end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6de5e1b..920186d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,7 +19,8 @@ module ApplicationHelper # http://twitter.github.io/bootstrap/base-css.html#icons # def icon(name, color=nil) - "<i class=\"icon-#{name} #{color_class(color)}\"></i> ".html_safe + content_tag :span, '', + class: "glyphicon glyphicon-#{name} #{color_class(color)}" end def big_icon(name, color=nil) diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index ddb063e..b74e1d7 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -39,6 +39,10 @@ module LinkHelper def btn(*args, &block) html_options = extract_html_options!(args, &block) type = Array(html_options.delete(:type)) + btn_opts = [:default, :primary, :success, :info, :warning, :danger, :link] + if (type & btn_opts).blank? + type << :default + end type.map! {|t| "btn-#{t}"} html_options[:class] = concat_classes(html_options[:class], 'btn', type) args[0] = t(args[0]) if args[0].is_a?(Symbol) diff --git a/app/helpers/navigation_helper.rb b/app/helpers/navigation_helper.rb index 2639246..1df840c 100644 --- a/app/helpers/navigation_helper.rb +++ b/app/helpers/navigation_helper.rb @@ -66,9 +66,9 @@ module NavigationHelper end def extract_icon!(options) - icon = options.delete(:icon) - if icon.present? - content_tag(:i, '', class: "icon-#{icon}") + name = options.delete(:icon) + if name.present? + icon name else "" end diff --git a/app/models/account.rb b/app/models/account.rb index 7310250..d722caa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -69,15 +69,13 @@ class Account @user.refresh_identity end - def destroy(destroy_identity=false) + def destroy(release_handles=false) return unless @user if !@user.is_tmp? - if destroy_identity == false - @user.identities.each do |id| + @user.identities.each do |id| + if release_handles == false id.orphan! - end - else - @user.identities.each do |id| + else id.destroy end end diff --git a/app/models/api_monitor_user.rb b/app/models/api_monitor_user.rb new file mode 100644 index 0000000..d0fe411 --- /dev/null +++ b/app/models/api_monitor_user.rb @@ -0,0 +1,11 @@ +# +# A user that has limited admin access, to be used +# for running monitor tests against a live production +# installation. +# +class ApiMonitorUser < ApiUser + def is_monitor? + true + end +end + diff --git a/app/models/api_user.rb b/app/models/api_user.rb index 2efe1cb..c70cccb 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -3,21 +3,10 @@ class ApiUser < AnonymousUser end # -# A user that has limited admin access, to be used -# for running monitor tests against a live production -# installation. -# -class ApiMonitorUser < ApiUser - def is_monitor? - true - end -end - -# # Not yet supported: # #class ApiAdminUser < ApiUser # def is_admin? # true # end -#end
\ No newline at end of file +#end diff --git a/app/models/email.rb b/app/models/email.rb deleted file mode 100644 index 4090275..0000000 --- a/app/models/email.rb +++ /dev/null @@ -1,31 +0,0 @@ -class Email < String - include ActiveModel::Validations - - validates :email, - :format => { - :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/, #local part of email is case-sensitive, so allow uppercase letter. - :message => "needs to be a valid email address" - } - - # Make sure we can call Email.new(nil) and get an invalid email address - def initialize(s) - super(s.to_s) - end - - def to_partial_path - "emails/email" - end - - def to_param - to_s - end - - def email - self - end - - def handle - self.split('@').first - end - -end diff --git a/app/models/identity.rb b/app/models/identity.rb index f987e4e..92f8f7a 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,3 +1,5 @@ +require 'login_format_validation' +require 'local_email' # # Identity states: # diff --git a/app/models/local_email.rb b/app/models/local_email.rb deleted file mode 100644 index ded7baf..0000000 --- a/app/models/local_email.rb +++ /dev/null @@ -1,66 +0,0 @@ -class LocalEmail < Email - - BLACKLIST_FROM_RFC2142 = [ - 'postmaster', 'hostmaster', 'domainadmin', 'webmaster', 'www', - 'abuse', 'noc', 'security', 'usenet', 'news', 'uucp', - 'ftp', 'sales', 'marketing', 'support', 'info' - ] - - def self.domain - APP_CONFIG[:domain] - end - - validates :email, - :format => { - :with => /@#{domain}\Z/i, - :message => "needs to end in @#{domain}" - } - - validate :handle_allowed - - def initialize(s) - super - append_domain_if_needed - end - - def to_key - [handle] - end - - def domain - LocalEmail.domain - end - - protected - - def append_domain_if_needed - unless self.index('@') - self << '@' + domain - end - end - - def handle_allowed - errors.add(:handle, "is reserved.") if handle_reserved? - end - - def handle_reserved? - # *ARRAY in a case statement tests if ARRAY includes the handle. - case handle - when *APP_CONFIG[:handle_blacklist] - true - when *APP_CONFIG[:handle_whitelist] - false - when *BLACKLIST_FROM_RFC2142 - true - else - handle_in_passwd? - end - end - - def handle_in_passwd? - Etc.getpwnam(handle).present? - rescue ArgumentError - # handle was not found - return false - end -end diff --git a/app/models/login_format_validation.rb b/app/models/login_format_validation.rb deleted file mode 100644 index c1fcf70..0000000 --- a/app/models/login_format_validation.rb +++ /dev/null @@ -1,21 +0,0 @@ -module LoginFormatValidation - extend ActiveSupport::Concern - - #TODO: Probably will replace this. Playing with using it for aliases too, but won't want it connected to login field. - - included do - # Have multiple regular expression validations so we can get specific error messages: - validates :login, - :format => { :with => /\A.{2,}\z/, - :message => "Must have at least two characters"} - validates :login, - :format => { :with => /\A[a-z\d_\.-]+\z/, - :message => "Only lowercase letters, digits, . - and _ allowed."} - validates :login, - :format => { :with => /\A[a-z].*\z/, - :message => "Must begin with a lowercase letter"} - validates :login, - :format => { :with => /\A.*[a-z\d]\z/, - :message => "Must end with a letter or digit"} - end -end diff --git a/app/models/token.rb b/app/models/token.rb index b398fcb..8ac32b8 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -59,8 +59,8 @@ class Token < CouchRest::Model::Base # So let's make sure we don't crash if they disappeared def destroy_with_rescue destroy_without_rescue - rescue RestClient::ResourceNotFound # do nothing it's gone already - rescue RestClient::Conflict # do nothing - it's been updated - #7670 + rescue CouchRest::NotFound + rescue CouchRest::Conflict # do nothing - it's been updated - #7670 end alias_method_chain :destroy, :rescue diff --git a/app/models/user.rb b/app/models/user.rb index e3246ad..6541305 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,6 @@ +require 'login_format_validation' +require 'local_email' + class User < CouchRest::Model::Base include LoginFormatValidation diff --git a/app/views/users/new.html.haml b/app/views/account/new.html.haml index bc0b1af..d40259e 100644 --- a/app/views/users/new.html.haml +++ b/app/views/account/new.html.haml @@ -1,18 +1,18 @@ -# -# This form is handled entirely by javascript -# Please take care when changing element ids. --# --# The form is hidden when no js is available +-# +-# The form is hidden when no js is available -# to prevent submission in the clear. -# - form_options = {url: '/not-used', html: {id: 'new_user', class: user_form_class('form-horizontal'), style: 'display:none'}, validate: true} -.span1 -.span9 +.col-md-1 +.col-md-9 %h2=t :signup .lead=t :signup_info - = render :partial => 'warnings' + = render "sessions/warnings" = simple_form_for(@user, form_options) do |f| = f.input :login, :label => t(:username), :required => false, :input_html => { :id => :srp_username } = f.input :password, :label => t(:password), :required => false, :validate => true, :input_html => { :id => :srp_password } diff --git a/app/views/v1/sessions/new.json.erb b/app/views/api/sessions/new.json.erb index 36154b8..36154b8 100644 --- a/app/views/v1/sessions/new.json.erb +++ b/app/views/api/sessions/new.json.erb diff --git a/app/views/common/_action_buttons.html.haml b/app/views/common/_action_buttons.html.haml index 81ebf67..eb663c0 100644 --- a/app/views/common/_action_buttons.html.haml +++ b/app/views/common/_action_buttons.html.haml @@ -1,14 +1,14 @@ .home-buttons - .row-fluid.second - .login.span4 + .row.second + .login.col-md-4 %span.link= btn icon('ok-sign') + t(:login), login_path %span.info= t(:login_info, default: "") - if APP_CONFIG[:allow_registration] - .signup.span4 + .signup.col-md-4 %span.link= btn icon('user') + t(:signup), signup_path %span.info= t(:signup_info, default: "") - else - .signup.span4 - .help.span4 + .signup.col-md-4 + .help.col-md-4 %span.link= btn icon('question-sign') + t(:get_help), new_ticket_path %span.info= t(:support_info, default: "") diff --git a/app/views/common/_download_button.html.haml b/app/views/common/_download_button.html.haml index 9c26860..1278230 100644 --- a/app/views/common/_download_button.html.haml +++ b/app/views/common/_download_button.html.haml @@ -1,8 +1,8 @@ .home-buttons - .row-fluid.first - .span2 - .download.span8 + .row.first + .col-md-2 + .download.col-md-8 = btn client_download_url, type: [:large, :primary] do = big_icon('download') = t(:download_bitmask) - .span2 + .col-md-2 diff --git a/app/views/common/_home_page_buttons.html.haml b/app/views/common/_home_page_buttons.html.haml index fc6348e..cfe3734 100644 --- a/app/views/common/_home_page_buttons.html.haml +++ b/app/views/common/_home_page_buttons.html.haml @@ -1,6 +1,6 @@ = render 'common/download_button' - if local_assigns[:divider] - .row-fluid - .span12 + .row + .col-md-12 = render local_assigns[:divider] = render 'common/action_buttons' diff --git a/app/views/home/_content.html.haml b/app/views/home/_content.html.haml index d96a1e0..67e4533 100644 --- a/app/views/home/_content.html.haml +++ b/app/views/home/_content.html.haml @@ -1,12 +1,12 @@ -.row-fluid +.row %h1= t(:welcome, :provider => APP_CONFIG[:domain]) .p=t(:welcome_message_html) -.row-fluid +.row = home_page_buttons - if Rails.env == 'development' - .row-fluid + .row %hr %p = link_to "make donation", new_payment_path if APP_CONFIG[:payment].present? diff --git a/app/views/layouts/_content.html.haml b/app/views/layouts/_content.html.haml index d5c2fa3..8e0bfbc 100644 --- a/app/views/layouts/_content.html.haml +++ b/app/views/layouts/_content.html.haml @@ -7,13 +7,13 @@ - else - content = yield -- if @show_navigation - .span2 +- if @show_navigation && @user + .col-md-2 = render 'layouts/navigation' - .span10 + .col-md-10 = render 'layouts/messages' = content - else - .span12 + .col-md-12 = render 'layouts/messages' = content diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 0aeda8b..e242a5f 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -15,13 +15,13 @@ #main .container-fluid - if @show_navigation && !admin? - .row-fluid + .row %h1= t(:user_control_panel) - if logged_in? - .row-fluid - .span12 + .row + .col-md-12 = render 'layouts/header' - .row-fluid + .row = render 'layouts/content' #push -# #push is used for sticky footer in bootstrap 2. remove when upgrading to bootstrap 3 diff --git a/app/views/users/_warnings.html.haml b/app/views/sessions/_warnings.html.haml index baf80a4..baf80a4 100644 --- a/app/views/users/_warnings.html.haml +++ b/app/views/sessions/_warnings.html.haml diff --git a/app/views/sessions/new.html.haml b/app/views/sessions/new.html.haml index bb7e4bd..6695123 100644 --- a/app/views/sessions/new.html.haml +++ b/app/views/sessions/new.html.haml @@ -1,8 +1,8 @@ -.span1 -.span9 +.col-md-1 +.col-md-9 %h2=t :login .lead=t :login_info - = render :partial => 'users/warnings' + = render 'warnings' = simple_form_for [:api, @session], validate: true, html: { id: :new_session, class: 'form-horizontal hidden js-show', style: "display:none;" } do |f| = f.input :login, :required => false, :label => t(:username), :input_html => { :id => :srp_username } = f.input :password, :required => false, :input_html => { :id => :srp_password } diff --git a/app/views/users/_change_password.html.haml b/app/views/users/_change_password.html.haml index 425e3ee..64a4d0a 100644 --- a/app/views/users/_change_password.html.haml +++ b/app/views/users/_change_password.html.haml @@ -15,7 +15,7 @@ = f.input :login, :label => t(:username), :required => false, :input_html => {:id => :srp_username} = f.input :password, :required => false, :validate => true, :input_html => { :id => :srp_password } = f.input :password_confirmation, :required => false, :input_html => { :id => :srp_password_confirmation } - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn btn-primary' diff --git a/app/views/users/_change_pgp_key.html.haml b/app/views/users/_change_pgp_key.html.haml index e465125..af3eb5e 100644 --- a/app/views/users/_change_pgp_key.html.haml +++ b/app/views/users/_change_pgp_key.html.haml @@ -8,6 +8,6 @@ = simple_form_for [:api, @user], form_options do |f| %legend= t(:advanced_options) = f.input :public_key, :as => :text, :hint => t(:use_ascii_key), :input_html => {:class => "full-width", :rows => 4} - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} diff --git a/app/views/users/_change_service_level.html.haml b/app/views/users/_change_service_level.html.haml index 42315a2..32ea8c0 100644 --- a/app/views/users/_change_service_level.html.haml +++ b/app/views/users/_change_service_level.html.haml @@ -1,8 +1,13 @@ --# TODO: probably won't want here, but here for now. Also, we will need way to ensure payment if they pick a non-free plan. --# --# SERVICE LEVEL --# -- if APP_CONFIG[:service_levels] +:ruby + # DISABLED! this form points to a route that does not exist. + # It's a draft for implementing service levels. + # TODO: probably won't want here, but here for now. + # We will need way to ensure payment for a non-free plan. + # + # SERVICE LEVEL + # + # +- if APP_CONFIG[:service_levels] && false - form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_service_level', :data => {token: session[:token]}}, :validate => true} = simple_form_for @user, form_options do |f| %legend= t(:service_level) @@ -13,6 +18,6 @@ %p = t(:effective_service_level) = f.select :effective_service_level_code, ServiceLevel.select_options, :selected => @user.effective_service_level.id - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} diff --git a/app/views/users/_contact_email.html.haml b/app/views/users/_contact_email.html.haml index ad768b7..1627ff6 100644 --- a/app/views/users/_contact_email.html.haml +++ b/app/views/users/_contact_email.html.haml @@ -4,6 +4,6 @@ = f.input :contact_email, :label => false -# %p= t(:public_key) -# = f.text_area :contact_email_key, {:class => "full-width", :rows => 4} - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."}
\ No newline at end of file diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index da8e467..3bdf952 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,6 +1,6 @@ = render 'overview' .container-fluid - .row-fluid + .row %h4 To use bitmask services: = btn client_download_url, type: "primary" do %i.icon-arrow-down.icon-white |