diff options
author | Jacob Schatz <jacobschatz@Jacobs-MBP.fios-router.home> | 2016-01-05 08:00:21 -0500 |
---|---|---|
committer | Jacob Schatz <jacobschatz@Jacobs-MBP.fios-router.home> | 2016-01-05 08:00:21 -0500 |
commit | 2b8457d60405f9b95fd204ecb4794083780d71ea (patch) | |
tree | 371fee8f8c3a295ed769bc4a405753989b95ac69 | |
parent | 6f4ea679d9ac18509df9216cef14f5d74ad19212 (diff) | |
download | gitlab-ce-new-dropdowns.tar.gz |
initial dropdown integration with gitlabnew-dropdowns
-rw-r--r-- | app/assets/javascripts/multiawesome.js | 583 | ||||
-rw-r--r-- | app/assets/stylesheets/multiawesome.css | 178 | ||||
-rw-r--r-- | app/controllers/projects/milestones_controller.rb | 4 | ||||
-rw-r--r-- | app/helpers/milestones_helper.rb | 13 | ||||
-rw-r--r-- | app/helpers/selects_helper.rb | 35 | ||||
-rw-r--r-- | app/views/shared/issuable/_filter.html.haml | 4 | ||||
-rw-r--r-- | vendor/assets/javascripts/sifter.js | 471 |
7 files changed, 1278 insertions, 10 deletions
diff --git a/app/assets/javascripts/multiawesome.js b/app/assets/javascripts/multiawesome.js new file mode 100644 index 00000000000..6e00874c421 --- /dev/null +++ b/app/assets/javascripts/multiawesome.js @@ -0,0 +1,583 @@ +//= require sifter + +/** + * multiawesome.js (v0.12.1) + * Copyright (c) 2015 Jacob Schatz & contributors + * + * 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. + * + * @author Jacob Schatz <jschatz@gitlab.com> + */ + +/*jshint curly:false */ +/*jshint browser:true */ +(function ( $ ) { + 'use strict'; + + var defaults = { + data: [], + title: '', + tip: '', + name: 'multi-awesome', + header: [], + multiple: false, + alwaysPrefixWithSearch: false, + placeholder: 'Filter...', + onChange: function(){}, + always: [], + dataObject: { + label: 'label', + data: 'data', + category: 'category', + subtitle: 'subtitle', + image: 'image' + }, + minSearchLength: 2 + }; + + $.fn.multiawesome = function( options ) { + var MultiAwesome = { + extraMenuContainerTemplate: '<li data-extra-menu-container class="dropdown-multi-menu-extras-container"></li>', + searchTemplate: '<li class="dropdown-multi-menu-search-container"><div class="input-with-icon"><i class="fa fa-search"></i><input type="text" id="multiawesome-search-input" /></div></li>', + itemContainerTemplate: '<li class="dropdown-multi-menu-selections"><ul></ul></li>', + itemTemplate: '<li><a href="#" class="item" data-item-selectable tabIndex="-1"><input type="checkbox" name="{{name}}" value="{{data}}"/>{{label}}</a></li>', + seperatorTemplate: '<li role="separator" class="divider"></li>', + headerTemplate: '<li class="dropdown-multi-menu-header"><div class="dropdown-multi-menu-header-area"><ul class="dropdown-multi-menu-header-list">{{header}}</ul></div></li>', + headerItemTemplate: '<li class="dropdown-multi-menu-header-item"><a href="#" data-header-selectable class="header-item" tabIndex="-1"><input name="{{name}}" type="checkbox" value="{{data}}" />{{headeritem}}</a></li>', + titleTemplate: '<li class="dropdown-multi-menu-title"><div class="dropdown-multi-menu-title-area"><a href="#" data-back-button class="dropdown-multi-menu-back-button"></a><h3 class="dropdown-multi-menu-title-text">{{title}}</h3></div></li>', + categoryContainerTemplate: '<li class="dropdown-multi-menu-category"><ul></ul></li>', + categoryItemTemplate: '<li><a href="#" class="category" data-category-selectable tabIndex="-1"><input type="checkbox" value="{{category}}"/>{{category}}</a></li>', + tipTemplate: '<li class="dropdown-multi-menu-tip"><div class="dropdown-multi-menu-tip-area"><p>{{tip}}</p></div></li>', + subtitleTemplate: '<p class="dropdown-multi-menu-subtitle">{{subtitle}}</p>', + imageTemplate: '<div class="dropdown-multi-menu-image" style="background-image:url(\'{{image}}\');"></div>' + }; + + + return this.each(function() { + var self = this, + categories = [], + $self = $(self), + $form = $(self).closest('form'), + $itemContainer, + $itemContainerUL, + $extraMenuContainer, + categoriesAppended = false, + categoriesSet = false, + selectedItems = [], + selectedCategories = [], + $searchInput, + $extraMenus, + $addedMenu, + $backButton, + $currentMenu, + toHideForExtraMenus = [], + sifter, + + // Merge the options and defaults into the settings. + // No need to check for undefined + settings = $.extend(true, {}, defaults, options, $self.data()); + + if( self.tagName !== 'UL') { + return; + } + + var prepareDropdown = function() { + var $searchTemplate = $(MultiAwesome.searchTemplate); + toHideForExtraMenus.push($searchTemplate); + $self.prepend($searchTemplate); + $searchInput = $searchTemplate.find('input'); + if( settings.placeholder ) { + $searchInput.prop( 'placeholder', settings.placeholder ); + } + }; + + var attachListeners = function() { + $self.on( 'click', '[data-category-selectable]' , dropdownCategoryLinkClicked ); + $self.on( 'click', + '[data-item-selectable], [data-header-selectable]', dropdownSelectionLinkClicked ); + $self.on( 'click', dropdownClickedAnywhere ); + $self.on( 'click', $backButton, backButtonClicked ); + $searchInput.on( 'keydown keyup update', inputSearched ); + }; + + var parseSearchResults = function(results) { + var finalData = []; + results.forEach( function( result ) { + finalData.push(settings.data[result.id]); + }); + renderData( finalData ); + }; + + var addCategories = function() { + var $categoryContainer = $(MultiAwesome.categoryContainerTemplate); + var $categoryContainerUL = $categoryContainer.find('ul'); + if( categories.length ) { + var $seperatorTemplate = $(MultiAwesome.seperatorTemplate); + toHideForExtraMenus.push($seperatorTemplate); + $self.prepend($seperatorTemplate); + categories.forEach(function( category ) { + $categoryContainerUL.prepend( MultiAwesome.categoryItemTemplate + .replace( /\{\{category\}\}/g, category ) ); + }); + $self.prepend($categoryContainer); + } + }; + + var addTitle = function() { + var titleTemplate; + if( settings.title ) { + var $seperatorTemplate = $(MultiAwesome.seperatorTemplate); + var $titleTemplate = $(MultiAwesome.titleTemplate); + $self.prepend($seperatorTemplate); + $self.prepend(MultiAwesome.titleTemplate + .replace(/\{\{title\}\}/g, settings.title) + ); + $backButton = $self.find('[data-back-button]'); + + } + }; + + var addExtrasContainer = function() { + $extraMenuContainer = $(MultiAwesome.extraMenuContainerTemplate); + $self.prepend($extraMenuContainer); + }; + + var addHeader = function() { + var $headerTemplate, + headerList = []; + // if we have some header data + if( settings.header.length ) { + settings.header.forEach( function(item) { + headerList.push( + MultiAwesome.headerItemTemplate + .replace(/\{\{headeritem\}\}/g, item[settings.dataObject.label]) + .replace(/\{\{data\}\}/g, item[settings.dataObject.data]) + .replace(/\{\{name\}\}/g, '_' + settings.name) + ); + }); + var $seperatorTemplate = $(MultiAwesome.seperatorTemplate); + toHideForExtraMenus.push($seperatorTemplate); + $self.prepend($seperatorTemplate); + $headerTemplate = $(MultiAwesome.headerTemplate.replace(/\{\{header\}\}/g, headerList.join(''))); + toHideForExtraMenus.push($headerTemplate); + $self.prepend($headerTemplate); + } + }; + + var addData = function(callback) { + function parseDataWhenReady() { + $itemContainer = $(MultiAwesome.itemContainerTemplate); + toHideForExtraMenus.push($itemContainer); + $itemContainerUL = $itemContainer.find('ul'); + + sifter = new Sifter(settings.data); + renderData( settings.data,callback ); + } + if ( settings.data ) { + if ( typeof settings.data === 'string') { + $.getJSON(settings.data, function(data) { + settings.data = data; + parseDataWhenReady(); + }); + } else if ( typeof settings.data === 'object' ) { + parseDataWhenReady(); + } else { + $.error('Data must be a string or array'); + } + } + }; + + var renderData = function( data, callback ) { + selectedItems = []; + $itemContainerUL.empty(); + var emptyObj = {}, + skipMatch = false, + searchInputVal = [], + o, + tempAlwaysData = [], + tempItemTemplate, + $itemTemplate; + + if( !data.length ) { + emptyObj[settings.dataObject.label] = 'No matches found'; + emptyObj[settings.dataObject.data] = 'dropdown-multi-menu-selectable:false'; + emptyObj[settings.dataObject.category] = ''; + emptyObj.selectable = false; + data.push(emptyObj); + skipMatch = true; + } + + if( settings.alwaysPrefixWithSearch && $searchInput ) { + searchInputVal = $searchInput.val(); + tempAlwaysData = settings.always.map(function(item){ + // copy, don't alter real object. + o = $.extend({}, item); + if(searchInputVal.length){ + o.label = '<strong>"' + searchInputVal + '"</strong>' + ' ' + o.label; + } + + o.always = true; + return o; + }); + } else { + tempAlwaysData = settings.always; + } + data = data.concat(tempAlwaysData); + data.forEach( function( item ) { + tempItemTemplate = MultiAwesome.itemTemplate; + if( !categoriesSet && !skipMatch ) { + var addCategory = item[settings.dataObject.category]; + if( item.hasOwnProperty( settings.dataObject.category ) && categories.indexOf( addCategory ) === -1 ) { + categories.push( addCategory ); + } + } else { + // only do this if the categories are already set... they won't search categories on the first time. + if( selectedCategories.length && + selectedCategories.indexOf(item[settings.dataObject.category]) === -1 && + !skipMatch) { + return; + } + } + + $itemTemplate = $(MultiAwesome.itemTemplate); + + if( item.hasOwnProperty('selectable') && !item.selectable ) { + tempItemTemplate = $itemTemplate + .find('a') + .addClass('disabled') + .find('input[type="checkbox"]') + .prop('disabled','disabled') + // back to the anchor tag + .end() + // back to the li + .end() + .get(0) + .outerHTML; + } + + if( item.hasOwnProperty( settings.dataObject.subtitle ) && + item[settings.dataObject.subtitle].length ) { + tempItemTemplate = $(tempItemTemplate) + .find('a') + .addClass('dropdown-multi-menu-item-with-subtitle') + .append( + MultiAwesome.subtitleTemplate + .replace(/\{\{subtitle\}\}/g, item[settings.dataObject.subtitle]) + ) + .end() + .get(0) + .outerHTML; + } + + if( item.hasOwnProperty( settings.dataObject.image ) && + item[settings.dataObject.image].length ) { + tempItemTemplate = $(tempItemTemplate) + .find('a') + .addClass('dropdown-multi-menu-with-image') + .prepend( + MultiAwesome.imageTemplate + .replace(/\{\{image\}\}/g, item[settings.dataObject.image]) + ) + .end() + .get(0) + .outerHTML; + } + + $itemContainerUL.append( + tempItemTemplate + .replace(/\{\{data\}\}/g,item[settings.dataObject.data]) + .replace(/\{\{label\}\}/g,item[settings.dataObject.label]) + .replace(/\{\{name\}\}/g, '_' + settings.name) + ); + }); + if( !categoriesSet && !categoriesAppended ) { + $self.append($itemContainer); + categoriesAppended = true; + } + + if( categories.length ){ + categoriesSet = true; + } + + if( callback ) { + callback(); + } + }; + + var addToForm = function( val ) { + $form.prepend('<input type="hidden" name="' + settings.name + '" value="' + val + '" />'); + }; + + var removeFromForm = function( val ) { + $form + .find('input[name="' + settings.name + '"][value="' + val + '"]') + .remove(); + }; + + var getExtraMenus = function() { + $extraMenus = $self + // get parent button group + .closest('div.button-group') + // get the extra menu divs + .find('[data-extra-menu]'); + }; + + /* * * * * * * * * * * * * * * */ + /* listeners + /* * * * * * * * * * * * * * * */ + + var backButtonClicked = function() { + toHideForExtraMenus.forEach(function(menuSection){ + menuSection.show(); + }); + $extraMenuContainer.empty(); + $backButton.hide(); + return false; + }; + + var inputSearched = function() { + + //remove current hidden inputs + $('input[type="hidden"][name="' + settings.name + '"]').remove(); + + if( $searchInput.val().length > settings.minSearchLength ) { + var results = sifter.search($searchInput.val(), { + fields: [settings.dataObject.label], + sort: [{field: settings.dataObject.label, direction: 'asc'}] + }); + parseSearchResults(results.items); + } else { + renderData( settings.data ); + } + }; + + var moveToExtraMenu = function($menu) { + var $cloneMenu = $menu.clone(); + toHideForExtraMenus.forEach(function(menuSection){ + menuSection.hide(); + }); + $currentMenu = $menu; + $extraMenuContainer.append($cloneMenu); + $cloneMenu.show(); + $backButton.show(); + }; + + var isExtraMenuValue = function( val ) { + var isMatch = false; + $extraMenus.each( function() { + var $this = $(this); + if(val === $this.data('menu-target-value')) { + isMatch = true; + moveToExtraMenu($this); + return; + } + }); + return isMatch; + }; + + var dropdownClickedAnywhere = function( e ) {}; + + var dropdownCategoryLinkClicked = function ( e ) { + var $target = $( e.currentTarget ), + $inp = $target.find( 'input' ), + val = $inp.val(), + i = selectedCategories.indexOf( val ); + + e.preventDefault(); + // if the checkbox is disabled. + if( $inp.prop('disabled') ) { + return false; + } + if ( i > -1 ) { + var spliced = selectedCategories.splice( i, 1 ); + $target.removeClass('selected'); + setTimeout( function() { + $inp.prop( 'checked', false ); + }, 0); + } else { + selectedCategories.push( val ); + $target.addClass('selected'); + setTimeout( function() { + $inp.prop( 'checked', true ); + }, 0); + } + + inputSearched(); + + $( e.target ).blur(); + return false; + }; + + var findItemWithData = function(searchData, id) { + var item = {}; + for (var i = searchData.length - 1; i >= 0; i--) { + item = searchData[i]; + if( item.hasOwnProperty(settings.dataObject.data) && + item[settings.dataObject.data] == id ) { + return item; + } + } + return undefined; + }; + + var dropdownSelectionLinkClicked = function ( e ) { + var $target = $( e.currentTarget ), + $inp = $target.find( 'input' ), + findItemInData, + val = $inp.val(), + i = selectedItems.indexOf( val ); + + e.preventDefault(); + + if( $inp.prop('disabled') ) { + return false; + } + + if( isExtraMenuValue( val ) ) { + return false; + } + + findItemInData = findItemWithData(settings.data, val); + if( typeof findItemInData === 'undefined' ) { + findItemInData = findItemWithData(settings.always, val); + } + + if( typeof findItemInData === 'undefined' ) { + findItemInData = findItemWithData(settings.header, val); + } + + if( typeof findItemInData === 'undefined' && + val === 'dropdown-multi-menu-selectable:false' ) { + // don't close the dropdown. + return false; + } + + if ( typeof findItemInData !== 'undefined' && + findItemInData.hasOwnProperty('selectable') && + findItemInData.selectable === false ) { + // don't close the dropdown + return false; + } + + if( findItemInData.hasOwnProperty( 'href' ) ) { + window.location.href = findItemInData.href; + return; + } + + if( findItemInData.hasOwnProperty('selectable') && + !findItemWithData.selectable ) { + return; + } + + + if ( i > -1 ) { + var spliced = selectedItems.splice( i, 1 ); + if( settings.multiple ) { + removeFromForm( spliced ); + } else { + $form + .find('input[name="' + settings.name + '"]') + .remove(); + } + + $target.removeClass('selected'); + setTimeout( function() { + $inp.prop( 'checked', false ); + }, 0); + } else { + if( !settings.multiple ) { + selectedItems = []; + $form + .find('input[name="' + settings.name + '"]') + .remove(); + $form + .find('input[name="_' + settings.name + '"]') + .parent() + .removeClass('selected'); + } + + selectedItems.push( val ); + addToForm( val ); + $target.addClass('selected'); + setTimeout( function() { + $inp.prop( 'checked', true ); + }, 0); + } + settings.onChange({"changed":findItemInData, "selected":selectedItems}); + // close the dropdown if single selection + // otherwise don't close the dropdown + if( settings.multiple ) { + $( e.target ).blur(); + return false; + } else { + var button = $self.siblings('.dropdown-toggle').first(); + button.contents() + .each( + function(){ + if ( this.nodeType === 3 && this.nodeValue.trim() ) { + this.textContent = $target.text(); + } + }); + } + }; + + var addTip = function() { + var $tipTemplate; + if( settings.tip ) { + var $seperatorTemplate = $(MultiAwesome.seperatorTemplate); + toHideForExtraMenus.push($seperatorTemplate); + $self.append($seperatorTemplate); + $tipTemplate = $(MultiAwesome.tipTemplate.replace(/\{\{tip\}\}/g, settings.tip)); + toHideForExtraMenus.push($tipTemplate); + $self.append($tipTemplate); + } + }; + + /* * * * * * * * * * * * * * * */ + /* setup + /* * * * * * * * * * * * * * * */ + + var shouldInit = function() { + if(!$self.hasClass('initialized')) { + $self.addClass('initialized'); + return true; + } + return false; + }; + + var setup = function() { + if(!shouldInit()){ + return; + } + addData(function(){ + addHeader(); + addCategories(); + prepareDropdown(); + addExtrasContainer(); + addTitle(); + attachListeners(); + addTip(); + getExtraMenus(); + }); + }; + + setup(); + + }); + }; + + $(function(){ + + $('[data-multi-awesome]').each(function(){ + $(this).multiawesome(); + }); + + }); +})( jQuery );
\ No newline at end of file diff --git a/app/assets/stylesheets/multiawesome.css b/app/assets/stylesheets/multiawesome.css new file mode 100644 index 00000000000..5e027fe6dc2 --- /dev/null +++ b/app/assets/stylesheets/multiawesome.css @@ -0,0 +1,178 @@ +.open>.dropdown-menu { + max-width: 320px; + border-radius: 0px; +} + +.dropdown-menu input[type='text'] { + margin: 0 5px; + width: 309px; + height: 35px; + padding-left: 6px; +} + +.dropdown-menu input[type='checkbox'] { + margin: 0 5px; + display: none; +} + +.dropdown-menu>li>a { + padding-left: 0; +} + +.dropdown-menu .dropdown-multi-menu-title-area h3 { + font-size: 15px; + text-align: center; + margin-top: 5px; +} + +.dropdown-menu .dropdown-multi-menu-title-area h3::after { + content: ""; + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAACGUlEQVQ4jZ2VMXbiMBCGf83KKcxzntOmI1VMEyj2BLpD9gRbwQVygeUCpMsdcgKfQaYJqeIu1RYGC/stNjNbJOaBY8hu/lrz69PoH0mJCBaLhVEArqMoxhf0vFgYARBFUawWT08my7KxiMD3/fhmOLz/H7PE2ruyLL8rpRCG4b0WEQjzeVXXxq3X59ba8zAMZ/1+Pz9l9PLyEiyXy0lRFGZb18bTOhYR6GgwiOdJcu3Wa9R1bZxz5r1mespwuVxOnHO/mBla69jv9R6jwSAmALgZDu9934+JCMyMoiiMtfYuTdOgbZSmaWCtvSuKwjAziOigVbpZGIbhDACKojCnSLMsOyTz/bipPTB879nUWgvnnNkjxdnZ2TMAbDab6zbZaDQ62FCjpS5SIgIAMDOOkTVSItLZ9CRJxnmez5gZzRqlFIgIQRBMhkfiRZ1uADzPeyUiiMiOTERARPA87/VY3VHDr+pDDxtVVXXJzLtjAm9HZmZUVXV5rO5DD9M0DbIsmzSXQkQ4dSntifpA2JWzdmxO5XRnuE92KmddOd0n3Rl+NgGNPpsoAoB5koy7yLpenH6/n49Go2l79udJMgaAbz9ub02+Wv2s69poreNer/cQhuHs4uJi0zbbV1mWcxHJt9sttnVtmPmPc+63VkpBEa205z36vh8fm4C2rq6ucgDTxFqUZbkiolwplavmCwDenvB/MWtr/wv5CwCanfXE6iK0AAAAAElFTkSuQmCC'); + background-repeat: no-repeat; + background-size: 11px; + width: 12px; + height: 12px; + display: inline-block; + position: absolute; + right: 13px; + top: 13px; + cursor: pointer; +} + +.dropdown-menu .dropdown-multi-menu-selections { + max-height: 150px; + overflow-y: scroll; + margin-top: 15px; +} + +.dropdown-menu .dropdown-multi-menu-category ul{ + padding: 0 5px; + list-style: none; + max-height: 65px; + overflow-y: scroll; +} + +.dropdown-menu ul.dropdown-multi-menu-header-list { + padding: 0 5px; + list-style: none; + max-height: 75px; + overflow-y: scroll; +} + +.dropdown-menu .dropdown-multi-menu-selections ul{ + padding: 0 5px; + list-style: none; +} + +.dropdown-menu .dropdown-multi-menu-selections li, +.dropdown-menu .dropdown-multi-menu-header-list li{ + padding: 7px 5px; +} + +.dropdown-menu .dropdown-multi-menu-selections +li.dropdown-multi-menu-list-striped:nth-child(odd){ + background: #F5F5F5; +} + +.dropdown-menu .dropdown-multi-menu-selections +li a.disabled{ + cursor: default; +} + +.dropdown-menu .dropdown-multi-menu-selections .dropdown-multi-menu-subtitle { + font-size: 12px; + color: #9E9E9E; +} + +[data-extra-menu] { + display: none; +} + +.dropdown-multi-menu-back-button { + width: 12px; + height: 12px; + display: none; + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAACDUlEQVRIibWWMZLaMBSGn2QBGS0MMJMUTCq7CdAEF9kLqNsqJ8gFlgvQUS0XIOcgF3CXagvMVvY2UKXF9srIYCQ5FQxkIdhL8kqN/H/vvV9PMsqyDPKG73ksA4BOp+Pk/Ybk3Thz3UGSJF8QQuB7HrRzQi4C5vN5LYqivhCCKSlZiRCnSNUXAVEU9eM4ftBaAyHEoTc3k063e32LDjPXWgPGGCilzude73te8b8CXmVOqdNoNMZFxE8CFotFLQzDw8wfKaWObdujouInAWEY7jMvlUoTSqnTK9iWk4A/MzcMA64VPwIEQdBfrVYPSqlr9F4Fevb92yAI7tM0bSutb7MsA4QQIISAEOKUy2WfEPIrj9huPjDGL5Vy2e90uw7hnH/lnH/DGAPCeL8xyzJI05RtNhuWd7B2+wzDgHeVyuTZ9zl+S9lFgtRqtR9Syo//o0Wf2u1HtFucTqeDQ5MJIVCtVvv/7BQ1m80xQgiEEExKya4RPYy9B6Zpctu2R5RSB2MMSikQQrDZbHZ/DcAYDodHC+v1+klrzZVSIKW8226375fL5YdWq/XzLQB07gi6rjs4ddmZpsmLAM7epvV6fQwAe0/iON75UujSOzsHlmUdeaK1BiEEeyroycUX7bASJSUTQrx4nufnffgvAizL4gAwmrkuJEnygjHmCKHcPpw1+VR4nscQQO4/CgCA3yx5RbfFRth/AAAAAElFTkSuQmCC'); + float: left; + background-size: 12px; + background-repeat: no-repeat; + margin-top: 3px; + margin-left: 10px; + margin-right: -10px; +} + +.dropdown-menu .dropdown-multi-menu-selections ul a, +.dropdown-menu .dropdown-multi-menu-category ul a, +.dropdown-menu .dropdown-multi-menu-header-item a{ + text-decoration: none; + color: #333; + display: inline-block; + width: 285px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-left: 30px; + vertical-align: middle; +} + +.dropdown-menu .dropdown-multi-menu-selections ul a:focus, +.dropdown-menu .dropdown-multi-menu-category ul a:focus, +.dropdown-menu .dropdown-multi-menu-header-item a:focus { + border: none; + outline: none; +} + +.dropdown-menu .dropdown-multi-menu-selections ul a.selected, +.dropdown-menu .dropdown-multi-menu-category ul a.selected, +.dropdown-menu .dropdown-multi-menu-header ul a.selected { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAYCAMAAADat72NAAABtlBMVEX////b29vc29vc3NzZ2dna2dnb2trY19fZ2NjV1dXW1tbT09PU09PV1NTR0dHS0dHS0tLPz8/Qz8/Q0NDNzc3Ozc3Ly8vMy8vMzMzJycnKycnKysrIyMjJyMjKyMjFxcXGxcXGxsbHxcXHxsbEw8PExMTFxMTBwcHBwsLCwcG/v7+9vb2+vr6/vr67u7u8vLy5ubm6ubm7ubm3t7e4t7e4uLi1tbW3tra0s7OysbGysrKzsbGvr6+wr6+urq6vrq6sq6utrKypqamrqqqsqqqop6epp6eqqKimpaWnpaWnpqako6OkpKSlo6OioaGjoqKkoqKgn5+goKChn5+hoKCenp6fnp6cm5ucnJydm5udnJyenJx+fX1/fHx/fX2Afn6Af3+Bf3+BgICCgICCgYGDgYGDgoKEgoKFgoKFg4OGhISGhYWHhYWIh4eJh4eJiIiJiYmKiIiLiYmLioqMioqMi4uNi4uNjIyOjIyPjY2Qjo6Qj4+SkJCSkZGTkZGTkpKUk5OVk5OVlJSWlZWXlZWYlpaYl5eYmJiZmJiZmZmamJibmZmbmpqcm5uenp6fn5+op6erqqouFWZ5AAAAXHRSTlMADw8PFhYWHR0kJCwsLDMzMzo6OkJCSUlJUFBQV1dXX19fX19mZmZtbW11fHx8g4OKioqSkpKZmaCoqKivr7a2vb3FxcXMzMzT09Pb29vi4uLp6enp8PD4+Pj4+E8lS9YAAAEjSURBVCjPY2DACYRtrDhwywralTZL45RldymLb5LEJctkWBOXHiSAS9q8OSOzxRiXrGJPWmqdNTcOWY3ggtQyN15cXvIpTCnxVcIhK+pYnZwZLYdDls22Nj2rywiHLKNeW2p8oxaMK6vGgiKt05edVGXPDOWJB7S6KyPJSgVnxZRb8sC4Co2JJV4icFkJl6KYXB8hOJ8/KiW5wl8TyuN0KonP81BEMs24Iye10A8S+Kwm1bGZESoobtHszkwtDtUGMS0aUjM7TdG8YhRZmJIdLc/IpNqWktpoxonuVeWw3OTC3gkT+/NTK525MINCN6QsNSuvND+93FUGW1CJeZekJiSn5wSKYw9KNc/q9IS8cH1cCYDPoT63XR13ypQwczbAEAQAnkJCZAp/V+QAAAAASUVORK5CYII='); + background-repeat: no-repeat; + background-size: 16px; + background-position: 2px 4px; +} + +.dropdown-menu .dropdown-multi-menu-selections ul a.selected.dropdown-multi-menu-item-with-subtitle, +.dropdown-menu .dropdown-multi-menu-selections ul a.selected.dropdown-multi-menu-with-image { + background-position: 2px 12px; +} + +.dropdown-menu .dropdown-multi-menu-image { + background-size: cover; + /*crop from the center*/ + background-position: center; + width: 30px; + height: 30px; + display: inline-block; + position: relative; + border-radius: 15px; + float: left; + margin-top: 3px; + margin-right: 11px; +} + +.dropdown-menu .dropdown-multi-menu-tip-area{ + margin: 10px; + text-align: left; + max-height: 40px; + overflow-y: scroll; +} + + .dropdown-menu .dropdown-multi-menu-header-area { + + } + +.dropdown-menu .dropdown-multi-menu-tip-area p { +} + +.dropdown-menu .input-with-icon { + position: relative; + margin: 10px 0; +} + +.dropdown-menu .input-with-icon i { + position: absolute; + right: 0; + padding: 10px 12px; + color: #817F7F; + pointer-events: none; +} diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 15506bd677a..5c999a40a53 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -19,6 +19,10 @@ class Projects::MilestonesController < Projects::ApplicationController @milestones = @milestones.includes(:project) @milestones = @milestones.page(params[:page]).per(PER_PAGE) + respond_to do |format| + format.html # show.html.erb + format.json { render :json => @milestones } + end end def new diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index a42cbcff182..91ffc1aab6b 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -20,7 +20,7 @@ module MilestonesHelper end end - def projects_milestones_options + def projects_milestones_data_options milestones = if @project @project.milestones @@ -28,12 +28,11 @@ module MilestonesHelper Milestone.where(project_id: @projects) end.active - epoch = DateTime.parse('1970-01-01') - grouped_milestones = GlobalMilestone.build_collection(milestones) - grouped_milestones = grouped_milestones.sort_by { |x| x.due_date.nil? ? epoch : x.due_date } - grouped_milestones.unshift(Milestone::None) - grouped_milestones.unshift(Milestone::Any) + milestones.as_json only: [:title, :id] + end - options_from_collection_for_select(grouped_milestones, 'name', 'title', params[:milestone_title]) + def projects_milestones_header_options + grouped_milestones = [Milestone::Any, Milestone::None].as_json end + end diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 05386d790ca..875acfd719c 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -36,6 +36,41 @@ module SelectsHelper hidden_field_tag(id, value, html) end + def multi_select_tag(name, opts = {}) + css_class = "dropdown-menu " + css_class << (opts[:class] || '') + header = opts[:header] || '' + ul_html = { + class: css_class, + data: { + header: header, + data: opts[:header_url], + "multi-awesome" => '', + "data-object" => { + label: "title", + data: "id" + } + } + } + + button_html = { + class: ["btn", "btn-default", "dropdown-toggle"], + type: "button", + data: { + toggle: "dropdown" + } + } + + button_class = "btn btn-default dropdown-toggle" + + content_tag :div, :class => "button-group" do + content_tag(:button, content_tag(:span, name) + + content_tag(:span,nil ,:class => "caret"), + button_html) + + content_tag(:ul, nil, ul_html) + end + end + def groups_select_tag(id, opts = {}) opts[:class] ||= '' opts[:class] << ' ajax-groups-select' diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index be06738eac9..9e800ffb37c 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -38,9 +38,7 @@ placeholder: 'Assignee', class: 'trigger-submit', any_user: "Any Assignee", null_user: true, first_user: true, current_user: true) .filter-item.inline.milestone-filter - = select_tag('milestone_title', projects_milestones_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Milestone'}) + = multi_select_tag('Milestone', class: 'milestone-filter', header: projects_milestones_header_options) .filter-item.inline.labels-filter = select_tag('label_name', projects_labels_options, diff --git a/vendor/assets/javascripts/sifter.js b/vendor/assets/javascripts/sifter.js new file mode 100644 index 00000000000..c80aec11c36 --- /dev/null +++ b/vendor/assets/javascripts/sifter.js @@ -0,0 +1,471 @@ +/** + * sifter.js + * Copyright (c) 2013 Brian Reavis & contributors + * + * 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. + * + * @author Brian Reavis <brian@thirdroute.com> + */ + +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + define(factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } else { + root.Sifter = factory(); + } +}(this, function() { + + /** + * Textually searches arrays and hashes of objects + * by property (or multiple properties). Designed + * specifically for autocomplete. + * + * @constructor + * @param {array|object} items + * @param {object} items + */ + var Sifter = function(items, settings) { + this.items = items; + this.settings = settings || {diacritics: true}; + }; + + /** + * Splits a search string into an array of individual + * regexps to be used to match results. + * + * @param {string} query + * @returns {array} + */ + Sifter.prototype.tokenize = function(query) { + query = trim(String(query || '').toLowerCase()); + if (!query || !query.length) return []; + + var i, n, regex, letter; + var tokens = []; + var words = query.split(/ +/); + + for (i = 0, n = words.length; i < n; i++) { + regex = escape_regex(words[i]); + if (this.settings.diacritics) { + for (letter in DIACRITICS) { + if (DIACRITICS.hasOwnProperty(letter)) { + regex = regex.replace(new RegExp(letter, 'g'), DIACRITICS[letter]); + } + } + } + tokens.push({ + string : words[i], + regex : new RegExp(regex, 'i') + }); + } + + return tokens; + }; + + /** + * Iterates over arrays and hashes. + * + * ``` + * this.iterator(this.items, function(item, id) { + * // invoked for each item + * }); + * ``` + * + * @param {array|object} object + */ + Sifter.prototype.iterator = function(object, callback) { + var iterator; + if (is_array(object)) { + iterator = Array.prototype.forEach || function(callback) { + for (var i = 0, n = this.length; i < n; i++) { + callback(this[i], i, this); + } + }; + } else { + iterator = function(callback) { + for (var key in this) { + if (this.hasOwnProperty(key)) { + callback(this[key], key, this); + } + } + }; + } + + iterator.apply(object, [callback]); + }; + + /** + * Returns a function to be used to score individual results. + * + * Good matches will have a higher score than poor matches. + * If an item is not a match, 0 will be returned by the function. + * + * @param {object|string} search + * @param {object} options (optional) + * @returns {function} + */ + Sifter.prototype.getScoreFunction = function(search, options) { + var self, fields, tokens, token_count; + + self = this; + search = self.prepareSearch(search, options); + tokens = search.tokens; + fields = search.options.fields; + token_count = tokens.length; + + /** + * Calculates how close of a match the + * given value is against a search token. + * + * @param {mixed} value + * @param {object} token + * @return {number} + */ + var scoreValue = function(value, token) { + var score, pos; + + if (!value) return 0; + value = String(value || ''); + pos = value.search(token.regex); + if (pos === -1) return 0; + score = token.string.length / value.length; + if (pos === 0) score += 0.5; + return score; + }; + + /** + * Calculates the score of an object + * against the search query. + * + * @param {object} token + * @param {object} data + * @return {number} + */ + var scoreObject = (function() { + var field_count = fields.length; + if (!field_count) { + return function() { return 0; }; + } + if (field_count === 1) { + return function(token, data) { + return scoreValue(data[fields[0]], token); + }; + } + return function(token, data) { + for (var i = 0, sum = 0; i < field_count; i++) { + sum += scoreValue(data[fields[i]], token); + } + return sum / field_count; + }; + })(); + + if (!token_count) { + return function() { return 0; }; + } + if (token_count === 1) { + return function(data) { + return scoreObject(tokens[0], data); + }; + } + + if (search.options.conjunction === 'and') { + return function(data) { + var score; + for (var i = 0, sum = 0; i < token_count; i++) { + score = scoreObject(tokens[i], data); + if (score <= 0) return 0; + sum += score; + } + return sum / token_count; + }; + } else { + return function(data) { + for (var i = 0, sum = 0; i < token_count; i++) { + sum += scoreObject(tokens[i], data); + } + return sum / token_count; + }; + } + }; + + /** + * Returns a function that can be used to compare two + * results, for sorting purposes. If no sorting should + * be performed, `null` will be returned. + * + * @param {string|object} search + * @param {object} options + * @return function(a,b) + */ + Sifter.prototype.getSortFunction = function(search, options) { + var i, n, self, field, fields, fields_count, multiplier, multipliers, get_field, implicit_score, sort; + + self = this; + search = self.prepareSearch(search, options); + sort = (!search.query && options.sort_empty) || options.sort; + + /** + * Fetches the specified sort field value + * from a search result item. + * + * @param {string} name + * @param {object} result + * @return {mixed} + */ + get_field = function(name, result) { + if (name === '$score') return result.score; + return self.items[result.id][name]; + }; + + // parse options + fields = []; + if (sort) { + for (i = 0, n = sort.length; i < n; i++) { + if (search.query || sort[i].field !== '$score') { + fields.push(sort[i]); + } + } + } + + // the "$score" field is implied to be the primary + // sort field, unless it's manually specified + if (search.query) { + implicit_score = true; + for (i = 0, n = fields.length; i < n; i++) { + if (fields[i].field === '$score') { + implicit_score = false; + break; + } + } + if (implicit_score) { + fields.unshift({field: '$score', direction: 'desc'}); + } + } else { + for (i = 0, n = fields.length; i < n; i++) { + if (fields[i].field === '$score') { + fields.splice(i, 1); + break; + } + } + } + + multipliers = []; + for (i = 0, n = fields.length; i < n; i++) { + multipliers.push(fields[i].direction === 'desc' ? -1 : 1); + } + + // build function + fields_count = fields.length; + if (!fields_count) { + return null; + } else if (fields_count === 1) { + field = fields[0].field; + multiplier = multipliers[0]; + return function(a, b) { + return multiplier * cmp( + get_field(field, a), + get_field(field, b) + ); + }; + } else { + return function(a, b) { + var i, result, a_value, b_value, field; + for (i = 0; i < fields_count; i++) { + field = fields[i].field; + result = multipliers[i] * cmp( + get_field(field, a), + get_field(field, b) + ); + if (result) return result; + } + return 0; + }; + } + }; + + /** + * Parses a search query and returns an object + * with tokens and fields ready to be populated + * with results. + * + * @param {string} query + * @param {object} options + * @returns {object} + */ + Sifter.prototype.prepareSearch = function(query, options) { + if (typeof query === 'object') return query; + + options = extend({}, options); + + var option_fields = options.fields; + var option_sort = options.sort; + var option_sort_empty = options.sort_empty; + + if (option_fields && !is_array(option_fields)) options.fields = [option_fields]; + if (option_sort && !is_array(option_sort)) options.sort = [option_sort]; + if (option_sort_empty && !is_array(option_sort_empty)) options.sort_empty = [option_sort_empty]; + + return { + options : options, + query : String(query || '').toLowerCase(), + tokens : this.tokenize(query), + total : 0, + items : [] + }; + }; + + /** + * Searches through all items and returns a sorted array of matches. + * + * The `options` parameter can contain: + * + * - fields {string|array} + * - sort {array} + * - score {function} + * - filter {bool} + * - limit {integer} + * + * Returns an object containing: + * + * - options {object} + * - query {string} + * - tokens {array} + * - total {int} + * - items {array} + * + * @param {string} query + * @param {object} options + * @returns {object} + */ + Sifter.prototype.search = function(query, options) { + var self = this, value, score, search, calculateScore; + var fn_sort; + var fn_score; + + search = this.prepareSearch(query, options); + options = search.options; + query = search.query; + + // generate result scoring function + fn_score = options.score || self.getScoreFunction(search); + + // perform search and sort + if (query.length) { + self.iterator(self.items, function(item, id) { + score = fn_score(item); + if (options.filter === false || score > 0) { + search.items.push({'score': score, 'id': id}); + } + }); + } else { + self.iterator(self.items, function(item, id) { + search.items.push({'score': 1, 'id': id}); + }); + } + + fn_sort = self.getSortFunction(search, options); + if (fn_sort) search.items.sort(fn_sort); + + // apply limits + search.total = search.items.length; + if (typeof options.limit === 'number') { + search.items = search.items.slice(0, options.limit); + } + + return search; + }; + + // utilities + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + var cmp = function(a, b) { + if (typeof a === 'number' && typeof b === 'number') { + return a > b ? 1 : (a < b ? -1 : 0); + } + a = asciifold(String(a || '')); + b = asciifold(String(b || '')); + if (a > b) return 1; + if (b > a) return -1; + return 0; + }; + + var extend = function(a, b) { + var i, n, k, object; + for (i = 1, n = arguments.length; i < n; i++) { + object = arguments[i]; + if (!object) continue; + for (k in object) { + if (object.hasOwnProperty(k)) { + a[k] = object[k]; + } + } + } + return a; + }; + + var trim = function(str) { + return (str + '').replace(/^\s+|\s+$|/g, ''); + }; + + var escape_regex = function(str) { + return (str + '').replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); + }; + + var is_array = Array.isArray || (typeof $ !== 'undefined' && $.isArray) || function(object) { + return Object.prototype.toString.call(object) === '[object Array]'; + }; + + var DIACRITICS = { + 'a': '[aÀÁÂÃÄÅàáâãäåĀāąĄ]', + 'c': '[cÇçćĆčČ]', + 'd': '[dđĐďĎð]', + 'e': '[eÈÉÊËèéêëěĚĒēęĘ]', + 'i': '[iÌÍÎÏìíîïĪī]', + 'l': '[lłŁ]', + 'n': '[nÑñňŇńŃ]', + 'o': '[oÒÓÔÕÕÖØòóôõöøŌō]', + 'r': '[rřŘ]', + 's': '[sŠšśŚ]', + 't': '[tťŤ]', + 'u': '[uÙÚÛÜùúûüůŮŪū]', + 'y': '[yŸÿýÝ]', + 'z': '[zŽžżŻźŹ]' + }; + + var asciifold = (function() { + var i, n, k, chunk; + var foreignletters = ''; + var lookup = {}; + for (k in DIACRITICS) { + if (DIACRITICS.hasOwnProperty(k)) { + chunk = DIACRITICS[k].substring(2, DIACRITICS[k].length - 1); + foreignletters += chunk; + for (i = 0, n = chunk.length; i < n; i++) { + lookup[chunk.charAt(i)] = k; + } + } + } + var regexp = new RegExp('[' + foreignletters + ']', 'g'); + return function(str) { + return str.replace(regexp, function(foreignletter) { + return lookup[foreignletter]; + }).toLowerCase(); + }; + })(); + + + // export + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + return Sifter; +})); + |