summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Schatz <jacobschatz@Jacobs-MBP.fios-router.home>2016-01-05 08:00:21 -0500
committerJacob Schatz <jacobschatz@Jacobs-MBP.fios-router.home>2016-01-05 08:00:21 -0500
commit2b8457d60405f9b95fd204ecb4794083780d71ea (patch)
tree371fee8f8c3a295ed769bc4a405753989b95ac69
parent6f4ea679d9ac18509df9216cef14f5d74ad19212 (diff)
downloadgitlab-ce-new-dropdowns.tar.gz
initial dropdown integration with gitlabnew-dropdowns
-rw-r--r--app/assets/javascripts/multiawesome.js583
-rw-r--r--app/assets/stylesheets/multiawesome.css178
-rw-r--r--app/controllers/projects/milestones_controller.rb4
-rw-r--r--app/helpers/milestones_helper.rb13
-rw-r--r--app/helpers/selects_helper.rb35
-rw-r--r--app/views/shared/issuable/_filter.html.haml4
-rw-r--r--vendor/assets/javascripts/sifter.js471
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;
+}));
+