summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2016-03-07 15:37:35 +0000
committerPhil Hughes <me@iamphill.com>2016-03-10 13:54:54 +0000
commit4407532911c2fb4841cc51f9779ef51ccb6de099 (patch)
tree0e1aef05b697a05757e513fbbf5cadf84c4fc02c
parent198926dc7f1bf539862ead04e1e3ebb9d5b9de31 (diff)
downloadgitlab-ce-4407532911c2fb4841cc51f9779ef51ccb6de099.tar.gz
GitLab dropdown JS
-rw-r--r--app/assets/javascripts/gl_dropdown.js.coffee194
-rw-r--r--app/assets/javascripts/lib/fuse.js20
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss5
-rw-r--r--app/helpers/dropdowns_helper.rb47
-rw-r--r--app/views/help/ui.html.haml45
5 files changed, 310 insertions, 1 deletions
diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee
new file mode 100644
index 00000000000..f15d65473c6
--- /dev/null
+++ b/app/assets/javascripts/gl_dropdown.js.coffee
@@ -0,0 +1,194 @@
+class GitLabDropdownFilter
+ BLUR_KEYCODES = [27, 40]
+
+ constructor: (@dropdown, @remote, @data, @callback) ->
+ @input = @dropdown.find(".dropdown-input-field")
+
+ # Key events
+ @input.on "keyup", (e) =>
+ blur_field = @shouldBlur e.keyCode
+ search_text = @input.val()
+
+ if blur_field
+ @input.blur()
+
+ if @remote
+ @remote search_text, (data) =>
+ @callback(data)
+ else
+ @filter search_text
+
+ shouldBlur: (keyCode) ->
+ return BLUR_KEYCODES.indexOf(keyCode) >= 0
+
+ filter: (search_text) ->
+ data = @data()
+ results = if search_text isnt "" then data.search(search_text) else data.list
+
+ @callback results
+
+class GitLabDropdownRemote
+ constructor: (@dataEndpoint, @options) ->
+
+ execute: ->
+ if typeof @dataEndpoint is "string"
+ @fetchData()
+ else if typeof @dataEndpoint is "function"
+ if @options.beforeSend
+ @options.beforeSend()
+
+ # Fetch the data by calling the data funcfion
+ @dataEndpoint (data) =>
+ if @options.success
+ @options.success(data)
+
+ if @options.beforeSend
+ @options.beforeSend()
+
+ # Fetch the data through ajax if the data is a string
+ fetchData: ->
+ $.ajax(
+ url: @dataEndpoint,
+ dataType: @options.dataType,
+ beforeSend: =>
+ if @options.beforeSend
+ @options.beforeSend()
+ success: (data) =>
+ if @options.success
+ @options.success(data)
+ )
+
+class GitLabDropdown
+ LOADING_CLASS = "is-loading"
+
+ constructor: (@el, @options) ->
+ self = @
+ @dropdown = $(@el).parent()
+ search_fields = if @options.search then @options.search.fields else [];
+
+ if @options.data
+ # Remote data
+ @remote = new GitLabDropdownRemote @options.data, {
+ dataType: @options.dataType,
+ beforeSend: @toggleLoading.bind(@)
+ success: (data) =>
+ @fullData = data
+ dataToPrase = @fullData
+
+ if @options.filterable
+ @fullData = new Fuse data, {
+ keys: search_fields
+ }
+ dataToPrase = @fullData.list
+
+ @parseData dataToPrase
+ }
+
+ # Init filiterable
+ if @options.filterable
+ @filter = new GitLabDropdownFilter @dropdown, @options.query, =>
+ return @fullData
+ , (data) =>
+ @parseData data
+
+ # Event listeners
+ $(@el).parent().on "shown.bs.dropdown", @opened
+
+ if @options.selectable
+ @dropdown.on "click", "a", (e) ->
+ self.rowClicked $(@)
+
+ if self.options.clicked
+ self.options.clicked()
+
+ toggleLoading: ->
+ $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS
+
+ parseData: (data) ->
+ @renderedData = data
+
+ # Render each row
+ html = $.map data, (obj) =>
+ return @renderItem(obj)
+
+ if @options.filterable and data.length is 0
+ # render no matching results
+ html = [@noResults()]
+
+ # Render the full menu
+ full_html = @renderMenu(html.join(""))
+
+ @appendMenu(full_html)
+
+ opened: =>
+ if @remote
+ @remote.execute()
+
+ # Render the full menu
+ renderMenu: (html) ->
+ menu_html = ""
+
+ if @options.renderMenu
+ menu_html = @options.renderMenu(html)
+ else
+ menu_html = "<ul>#{html}</ul>"
+
+ return menu_html
+
+ # Append the menu into the dropdown
+ appendMenu: (html) ->
+ $('.dropdown-content', @dropdown).html html
+
+ # Render the row
+ renderItem: (data) ->
+ html = ""
+
+ if @options.renderRow
+ # Call the render function
+ html = @options.renderRow(data)
+ else
+ selected = if @options.isSelected then @options.isSelected(data) else false
+ url = if @options.url then @options.url(data) else ""
+ text = if @options.text then @options.text(data) else ""
+ cssClass = "";
+
+ if selected
+ cssClass = "is-active"
+
+ html = "<li>"
+ html += "<a href='#{url}' class='#{cssClass}'>"
+ html += text
+ html += "</a>"
+ html += "</li>"
+
+ return html
+
+ noResults: ->
+ html = "<li>"
+ html += "<a href='#' class='is-focused'>"
+ html += "No matching results."
+ html += "</a>"
+ html += "</li>"
+
+ rowClicked: (el) ->
+ fieldName = @options.fieldName
+ selectedIndex = el.parent().index()
+ selectedObject = @renderedData[selectedIndex]
+ value = if @options.id then @options.id(selectedObject) else selectedObject.id
+
+ if @options.multiSelect
+ fieldName = "[#{fieldName}]"
+ else
+ @dropdown.find('.is-active').removeClass 'is-active'
+ @dropdown.parent().find("input[name='#{fieldName}']").remove()
+
+ # Toggle active class for the tick mark
+ el.toggleClass "is-active"
+
+ # Create hidden input for form
+ input = "<input type='hidden' name='#{fieldName}' value='#{value}' />"
+ @dropdown.before input
+
+$.fn.glDropdown = (opts) ->
+ return @.each ->
+ new GitLabDropdown @, opts
diff --git a/app/assets/javascripts/lib/fuse.js b/app/assets/javascripts/lib/fuse.js
new file mode 100644
index 00000000000..09f4ad0aacc
--- /dev/null
+++ b/app/assets/javascripts/lib/fuse.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Fuse - Lightweight fuzzy-search
+ *
+ * Copyright (c) 2012-2016 Kirollos Risk <kirollos@gmail.com>.
+ * All Rights Reserved. Apache Software License 2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License")
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+!function(t){"use strict";function e(){console.log.apply(console,arguments)}function s(t,e){var s,n,i,o;for(this.list=t,this.options=e=e||{},s=0,o=["sort","shouldSort","verbose","tokenize"],n=o.length;n>s;s++)i=o[s],this.options[i]=i in e?e[i]:h[i];for(s=0,o=["searchFn","sortFn","keys","getFn","include"],n=o.length;n>s;s++)i=o[s],this.options[i]=e[i]||h[i]}function n(t,e,s){var o,r,h,a,c,p;if(e){if(h=e.indexOf("."),-1!==h?(o=e.slice(0,h),r=e.slice(h+1)):o=e,a=t[o],null!==a&&void 0!==a)if(r||"string"!=typeof a&&"number"!=typeof a)if(i(a))for(c=0,p=a.length;p>c;c++)n(a[c],r,s);else r&&n(a,r,s);else s.push(a)}else s.push(t);return s}function i(t){return"[object Array]"===Object.prototype.toString.call(t)}function o(t,e){e=e||{},this.options=e,this.options.location=e.location||o.defaultOptions.location,this.options.distance="distance"in e?e.distance:o.defaultOptions.distance,this.options.threshold="threshold"in e?e.threshold:o.defaultOptions.threshold,this.options.maxPatternLength=e.maxPatternLength||o.defaultOptions.maxPatternLength,this.pattern=e.caseSensitive?t:t.toLowerCase(),this.patternLen=t.length,this.patternLen<=this.options.maxPatternLength&&(this.matchmask=1<<this.patternLen-1,this.patternAlphabet=this._calculatePatternAlphabet())}var r=/ +/g,h={id:null,caseSensitive:!1,include:[],shouldSort:!0,searchFn:o,sortFn:function(t,e){return t.score-e.score},getFn:n,keys:[],verbose:!1,tokenize:!1};s.VERSION="2.2.0-beta",s.prototype.set=function(t){return this.list=t,t},s.prototype.search=function(t){this.options.verbose&&e("\nSearch term:",t,"\n"),this.pattern=t,this.results=[],this.resultMap={},this._keyMap=null,this._prepareSearchers(),this._startSearch(),this._computeScore(),this._sort();var s=this._format();return s},s.prototype._prepareSearchers=function(){var t=this.options,e=this.pattern,s=t.searchFn,n=e.split(r),i=0,o=n.length;if(this.options.tokenize)for(this.tokenSearchers=[];o>i;i++)this.tokenSearchers.push(new s(n[i],t));this.fullSeacher=new s(e,t)},s.prototype._startSearch=function(){var t,e,s,n,i=this.options,o=i.getFn,r=this.list,h=r.length,a=this.options.keys,c=a.length,p=null;if("string"==typeof r[0])for(s=0;h>s;s++)this._analyze("",r[s],s,s);else for(this._keyMap={},s=0;h>s;s++)for(p=r[s],n=0;c>n;n++){if(t=a[n],"string"!=typeof t){if(e=1-t.weight||1,this._keyMap[t.name]={weight:e},t.weight<=0||t.weight>1)throw new Error("Key weight has to be > 0 and <= 1");t=t.name}else this._keyMap[t]={weight:1};this._analyze(t,o(p,t,[]),p,s)}},s.prototype._analyze=function(t,s,n,o){var h,a,c,p,l,u,f,d,g,m,y,v,b,S,k,_=this.options,M=!1;if(void 0!==s&&null!==s)if(a=[],"string"==typeof s){if(h=s.split(r),_.verbose&&e("---------\nKey:",t),_.verbose&&e("Record:",h),this.options.tokenize){for(c=this.tokenSearchers,p=c.length,S=0;S<this.tokenSearchers.length;S++){for(m=this.tokenSearchers[S],y=[],k=0;k<h.length;k++)v=h[k],b=m.search(v),b.isMatch?(M=!0,y.push(b.score),a.push(b.score)):(y.push(1),a.push(1));_.verbose&&e("Token scores:",y)}for(u=a[0],d=a.length,S=1;d>S;S++)u+=a[S];u/=d,_.verbose&&e("Token score average:",u)}g=this.fullSeacher.search(s),_.verbose&&e("Full text score:",g.score),f=g.score,void 0!==u&&(f=(f+u)/2),_.verbose&&e("Score average:",f),(M||g.isMatch)&&(l=this.resultMap[o],l?l.output.push({key:t,score:f,matchedIndices:g.matchedIndices}):(this.resultMap[o]={item:n,output:[{key:t,score:f,matchedIndices:g.matchedIndices}]},this.results.push(this.resultMap[o])))}else if(i(s))for(S=0;S<s.length;S++)this._analyze(t,s[S],n,o)},s.prototype._computeScore=function(){var t,s,n,i,o,r,h,a,c,p=this._keyMap,l=this.results;for(this.options.verbose&&e("\n\nComputing score:\n"),t=0;t<l.length;t++){for(n=0,i=l[t].output,o=i.length,a=1,s=0;o>s;s++)r=i[s].score,h=p?p[i[s].key].weight:1,c=r*h,1!==h?a=Math.min(a,c):(n+=c,i[s].nScore=c);1===a?l[t].score=n/o:l[t].score=a,this.options.verbose&&e(l[t])}},s.prototype._sort=function(){var t=this.options;t.shouldSort&&(t.verbose&&e("\n\nSorting...."),this.results.sort(t.sortFn))},s.prototype._format=function(){var t,s,n,i,o,r=this.options,h=r.getFn,a=[],c=this.results,p=r.include;for(r.verbose&&e("\n\nOutput:\n\n",c),i=r.id?function(t){c[t].item=h(c[t].item,r.id,[])[0]}:function(){},o=function(t){var e,s,n,i,o,r=c[t];if(p.length>0){if(e={item:r.item},-1!==p.indexOf("matches"))for(n=r.output,e.matches=[],s=0;s<n.length;s++)i=n[s],o={indices:i.matchedIndices},i.key&&(o.key=i.key),e.matches.push(o);-1!==p.indexOf("score")&&(e.score=c[t].score)}else e=r.item;return e},s=0,n=c.length;n>s;s++)i(s),t=o(s),a.push(t);return a},o.defaultOptions={location:0,distance:100,threshold:.6,maxPatternLength:32},o.prototype._calculatePatternAlphabet=function(){var t={},e=0;for(e=0;e<this.patternLen;e++)t[this.pattern.charAt(e)]=0;for(e=0;e<this.patternLen;e++)t[this.pattern.charAt(e)]|=1<<this.pattern.length-e-1;return t},o.prototype._bitapScore=function(t,e){var s=t/this.patternLen,n=Math.abs(this.options.location-e);return this.options.distance?s+n/this.options.distance:n?1:s},o.prototype.search=function(t){var e,s,n,i,o,h,a,c,p,l,u,f,d,g,m,y,v,b,S,k,_,M,L=this.options;if(t=L.caseSensitive?t:t.toLowerCase(),this.pattern===t)return{isMatch:!0,score:0,matchedIndices:[[0,t.length-1]]};if(this.patternLen>L.maxPatternLength){if(v=t.match(new RegExp(this.pattern.replace(r,"|"))),b=!!v)for(k=[],e=0,_=v.length;_>e;e++)M=v[e],k.push([t.indexOf(M),M.length-1]);return{isMatch:b,score:b?.5:1,matchedIndices:k}}for(i=L.location,n=t.length,o=L.threshold,h=t.indexOf(this.pattern,i),S=[],e=0;n>e;e++)S[e]=0;for(-1!=h&&(o=Math.min(this._bitapScore(0,h),o),h=t.lastIndexOf(this.pattern,i+this.patternLen),-1!=h&&(o=Math.min(this._bitapScore(0,h),o))),h=-1,m=1,y=[],p=this.patternLen+n,e=0;e<this.patternLen;e++){for(a=0,c=p;c>a;)this._bitapScore(e,i+c)<=o?a=c:p=c,c=Math.floor((p-a)/2+a);for(p=c,l=Math.max(1,i-c+1),u=Math.min(i+c,n)+this.patternLen,f=Array(u+2),f[u+1]=(1<<e)-1,s=u;s>=l;s--)if(g=this.patternAlphabet[t.charAt(s-1)],g&&(S[s-1]=1),0===e?f[s]=(f[s+1]<<1|1)&g:f[s]=(f[s+1]<<1|1)&g|((d[s+1]|d[s])<<1|1)|d[s+1],f[s]&this.matchmask&&(m=this._bitapScore(e,s-1),o>=m)){if(o=m,h=s-1,y.push(h),!(h>i))break;l=Math.max(1,2*i-h)}if(this._bitapScore(e+1,i)>o)break;d=f}return k=this._getMatchedIndices(S),{isMatch:h>=0,score:0===m?.001:m,matchedIndices:k}},o.prototype._getMatchedIndices=function(t){for(var e,s=[],n=-1,i=-1,o=0,r=r=t.length;r>o;o++)e=t[o],e&&-1===n?n=o:e||-1===n||(i=o-1,s.push([n,i]),n=-1);return t[o-1]&&s.push([n,o-1]),s},"object"==typeof exports?module.exports=s:"function"==typeof define&&define.amd?define(function(){return s}):t.Fuse=s}(this);
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index e5e1c8310ed..1ef9d001ed4 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -117,9 +117,12 @@
white-space: nowrap;
overflow: hidden;
- &:hover {
+ &:hover,
+ &:focus,
+ &.is-focused {
background-color: $dropdown-link-hover-bg;
text-decoration: none;
+ outline: 0;
}
}
}
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
new file mode 100644
index 00000000000..c96b6eeebd5
--- /dev/null
+++ b/app/helpers/dropdowns_helper.rb
@@ -0,0 +1,47 @@
+module DropdownsHelper
+ def dropdown_tag(toggle_text, title: false, filter: false, placeholder: "", &block)
+ content_tag :div, class: "dropdown" do
+ dropdown_output = ""
+ dropdown_output += content_tag :button, class: "dropdown-menu-toggle", type: "button", data: {toggle: "dropdown"} do
+ output = toggle_text
+ output << icon('chevron-down')
+ output.html_safe
+ end
+
+ dropdown_output += content_tag :div, class: "dropdown-menu dropdown-select dropdown-menu-selectable" do
+ output = ""
+
+ if title
+ output += content_tag :div, class: "dropdown-title" do
+ title_output = content_tag(:span, title)
+
+ title_output += content_tag :button, class: "dropdown-title-button dropdown-menu-close", aria: {label: "close"} do
+ icon('times')
+ end.html_safe
+ end
+ end
+
+ if filter
+ output += content_tag :div, class: "dropdown-input" do
+ filter_output = search_field_tag nil, nil, class: "dropdown-input-field", placeholder: placeholder
+ filter_output += icon('search')
+
+ filter_output.html_safe
+ end
+ end
+
+ output += content_tag :div, class: "dropdown-content" do
+ capture(&block) if block
+ end
+
+ output += content_tag :div, class: "dropdown-loading" do
+ icon('spinner spin')
+ end
+
+ output.html_safe
+ end
+
+ dropdown_output.html_safe
+ end
+ end
+end
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 0123834bc31..db46e51f8d7 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -390,6 +390,51 @@
%button.btn.btn-primary
Create
+ .example
+ %div
+ .dropdown.inline
+ %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}}
+ Projects
+ = icon('chevron-down')
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ .dropdown-title
+ %span Go to project
+ %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}}
+ = icon('times')
+ .dropdown-input
+ %input.dropdown-input-field{type: "search", placeholder: "Filter results"}
+ = icon('search')
+ .dropdown-content
+ .dropdown-loading
+ = icon('spinner spin')
+ :javascript
+ $('#js-project-dropdown').glDropdown({
+ data: function (callback) {
+ Api.projects("", "last_activity_at", function (data) {
+ callback(data);
+ });
+ },
+ text: function (project) {
+ return project.name_with_namespace || project.name;
+ },
+ selectable: true,
+ fieldName: "author_id",
+ filterable: true,
+ search: {
+ fields: ['name_with_namespace']
+ },
+ id: function (data) {
+ return data.id;
+ },
+ isSelected: function (data) {
+ return data.id === 2;
+ }
+ })
+
+ .example
+ %div
+ = dropdown_tag("Projects", title: "Go to project", filter: true, placeholder: "Filter projects")
+
%h2#panels Panels
.row