diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index e5dfa30edab..602a3b78189 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -6,23 +6,60 @@ var slice = [].slice; window.GroupsSelect = (function() { function GroupsSelect() { $('.ajax-groups-select').each((function(_this) { + const self = _this; + return function(i, select) { var all_available, skip_groups; - all_available = $(select).data('all-available'); - skip_groups = $(select).data('skip-groups') || []; - return $(select).select2({ + const $select = $(select); + all_available = $select.data('all-available'); + skip_groups = $select.data('skip-groups') || []; + + $select.select2({ placeholder: "Search for a group", - multiple: $(select).hasClass('multiselect'), + multiple: $select.hasClass('multiselect'), minimumInputLength: 0, - query: function(query) { - var options = { all_available: all_available, skip_groups: skip_groups }; - return Api.groups(query.term, options, function(groups) { - var data; - data = { - results: groups + ajax: { + url: Api.buildUrl(Api.groupsPath), + dataType: 'json', + quietMillis: 250, + transport: function (params) { + $.ajax(params).then((data, status, xhr) => { + const results = data || []; + + const headers = gl.utils.normalizeCRLFHeaders(xhr.getAllResponseHeaders()); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; + + return { + results, + pagination: { + more, + }, + }; + }).then(params.success).fail(params.error); + }, + data: function (search, page) { + return { + search, + page, + per_page: GroupsSelect.PER_PAGE, + all_available, + skip_groups, }; - return query.callback(data); - }); + }, + results: function (data, page) { + if (data.length) return { results: [] }; + + const results = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + + return { + results, + page, + more, + }; + }, }, initSelection: function(element, callback) { var id; @@ -34,19 +71,23 @@ window.GroupsSelect = (function() { formatResult: function() { var args; args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); + return self.formatResult.apply(self, args); }, formatSelection: function() { var args; args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); + return self.formatSelection.apply(self, args); }, - dropdownCssClass: "ajax-groups-dropdown", + dropdownCssClass: "ajax-groups-dropdown select2-infinite", // we do not want to escape markup since we are displaying html in results escapeMarkup: function(m) { return m; } }); + + self.dropdown = document.querySelector('.select2-infinite .select2-results'); + + $select.on('select2-loaded', self.forceOverflow.bind(self)); }; })(this)); } @@ -65,5 +106,12 @@ window.GroupsSelect = (function() { return group.full_name; }; + GroupsSelect.prototype.forceOverflow = function (e) { + const itemHeight = this.dropdown.querySelector('.select2-result:first-child').clientHeight; + this.dropdown.style.height = `${Math.floor(this.dropdown.scrollHeight - (itemHeight * 0.9))}px`; + }; + + GroupsSelect.PER_PAGE = 20; + return GroupsSelect; })(); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a1423b6fda5..4aad0128aef 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -231,6 +231,22 @@ return upperCaseHeaders; }; + /** + this will take in the getAllResponseHeaders result and normalize them + this way we don't run into production issues when nginx gives us lowercased header keys + */ + w.gl.utils.normalizeCRLFHeaders = (headers) => { + const headersObject = {}; + const headersArray = headers.split('\n'); + + headersArray.forEach((header) => { + const keyValue = header.split(': '); + headersObject[keyValue[0]] = keyValue[1]; + }); + + return w.gl.utils.normalizeHeaders(headersObject); + }; + /** * Parses pagination object string values into numbers. * diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb index 4c28205da9b..c969acc9140 100644 --- a/spec/features/projects/group_links_spec.rb +++ b/spec/features/projects/group_links_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Project group links', feature: true, js: true do +feature 'Project group links', :feature, :js do include Select2Helper let(:master) { create(:user) } @@ -51,4 +51,24 @@ feature 'Project group links', feature: true, js: true do end end end + + describe 'the groups dropdown' do + before do + group_two = create(:group) + group.add_owner(master) + group_two.add_owner(master) + + visit namespace_project_settings_members_path(project.namespace, project) + execute_script 'GroupsSelect.PER_PAGE = 1;' + open_select2 '#link_group_id' + end + + it 'should infinitely scroll' do + expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 1) + + scroll_select2_to_bottom('.select2-drop .select2-results:visible') + + expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 2) + end + end end diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index d2e24eb7eb2..7cf39d37181 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -108,6 +108,37 @@ require('~/lib/utils/common_utils'); }); }); + describe('gl.utils.normalizeCRLFHeaders', () => { + beforeEach(function () { + this.CLRFHeaders = 'a-header: a-value\nAnother-Header: ANOTHER-VALUE\nLaSt-HeAdEr: last-VALUE'; + + spyOn(String.prototype, 'split').and.callThrough(); + spyOn(gl.utils, 'normalizeHeaders').and.callThrough(); + + this.normalizeCRLFHeaders = gl.utils.normalizeCRLFHeaders(this.CLRFHeaders); + }); + + it('should split by newline', function () { + expect(String.prototype.split).toHaveBeenCalledWith('\n'); + }); + + it('should split by colon+space for each header', function () { + expect(String.prototype.split.calls.allArgs().filter(args => args[0] === ': ').length).toBe(3); + }); + + it('should call gl.utils.normalizeHeaders with a parsed headers object', function () { + expect(gl.utils.normalizeHeaders).toHaveBeenCalledWith(jasmine.any(Object)); + }); + + it('should return a normalized headers object', function () { + expect(this.normalizeCRLFHeaders).toEqual({ + 'A-HEADER': 'a-value', + 'ANOTHER-HEADER': 'ANOTHER-VALUE', + 'LAST-HEADER': 'last-VALUE', + }); + }); + }); + describe('gl.utils.parseIntPagination', () => { it('should parse to integers all string values and return pagination object', () => { const pagination = { diff --git a/spec/support/select2_helper.rb b/spec/support/select2_helper.rb index 0d526045012..6b1853c2364 100644 --- a/spec/support/select2_helper.rb +++ b/spec/support/select2_helper.rb @@ -22,4 +22,12 @@ module Select2Helper execute_script("$('#{selector}').select2('val', '#{value}').trigger('change');") end end + + def open_select2(selector) + execute_script("$('#{selector}').select2('open');") + end + + def scroll_select2_to_bottom(selector) + evaluate_script "$('#{selector}').scrollTop($('#{selector}')[0].scrollHeight); $('#{selector}');" + end end