diff options
43 files changed, 1428 insertions, 128 deletions
diff --git a/CHANGELOG b/CHANGELOG index 9474ca50b24..aaffa51898d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -14,6 +14,8 @@ v 8.6.0 (unreleased) - Strip leading and trailing spaces in URL validator (evuez) - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez) - Return empty array instead of 404 when commit has no statuses in commit status API + - Decrease the font size and the padding of the `.anchor` icons used in the README (Roberto Dip) + - Rewrite logo to simplify SVG code (Sean Lang) - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users @@ -24,6 +26,7 @@ v 8.6.0 (unreleased) - Show labels in dashboard and group milestone views - Add main language of a project in the list of projects (Tiago Botelho) - Add ability to show archived projects on dashboard, explore and group pages + - Fix pagination for filtered dashboard and explore pages v 8.5.5 - Ensure removing a project removes associated Todo entries. diff --git a/app/assets/javascripts/api.js.coffee b/app/assets/javascripts/api.js.coffee index 3e0fdb3f795..2ddf8612db3 100644 --- a/app/assets/javascripts/api.js.coffee +++ b/app/assets/javascripts/api.js.coffee @@ -4,6 +4,7 @@ namespaces_path: "/api/:version/namespaces.json" group_projects_path: "/api/:version/groups/:id/projects.json" projects_path: "/api/:version/projects.json" + labels_path: "/api/:version/projects/:id/labels" group: (group_id, callback) -> url = Api.buildUrl(Api.group_path) @@ -61,6 +62,19 @@ ).done (projects) -> callback(projects) + newLabel: (project_id, data, callback) -> + url = Api.buildUrl(Api.labels_path) + url = url.replace(':id', project_id) + + data.private_token = gon.api_token + $.ajax( + url: url + type: "POST" + data: data + dataType: "json" + ).done (label) -> + callback(label) + # Return group projects list. Filtered by query groupProjects: (group_id, query, callback) -> url = Api.buildUrl(Api.group_projects_path) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee new file mode 100644 index 00000000000..8e1449bc59c --- /dev/null +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -0,0 +1,270 @@ +class GitLabDropdownFilter + BLUR_KEYCODES = [27, 40] + + constructor: (@dropdown, @options) -> + @input = @dropdown.find(".dropdown-input .dropdown-input-field") + + # Key events + timeout = "" + @input.on "keyup", (e) => + if e.keyCode is 13 && @input.val() isnt "" + if @options.enterCallback + @options.enterCallback() + return + + clearTimeout timeout + timeout = setTimeout => + blur_field = @shouldBlur e.keyCode + search_text = @input.val() + + if blur_field + @input.blur() + + if @options.remote + @options.query search_text, (data) => + @options.callback(data) + else + @filter search_text + , 250 + + shouldBlur: (keyCode) -> + return BLUR_KEYCODES.indexOf(keyCode) >= 0 + + filter: (search_text) -> + data = @options.data() + results = data + + if search_text isnt "" + results = fuzzaldrinPlus.filter(data, search_text, + key: @options.keys + ) + + @options.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" + PAGE_TWO_CLASS = "is-page-two" + ACTIVE_CLASS = "is-active" + + 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 + + @parseData @fullData + } + + # Init filiterable + if @options.filterable + @filter = new GitLabDropdownFilter @dropdown, + remote: @options.filterRemote + query: @options.data + keys: @options.search.fields + data: => + return @fullData + callback: (data) => + @parseData data + enterCallback: => + @selectFirstRow() + + # Event listeners + @dropdown.on "shown.bs.dropdown", @opened + @dropdown.on "hidden.bs.dropdown", @hidden + + if @dropdown.find(".dropdown-toggle-page").length + @dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on "click", (e) => + e.preventDefault() + e.stopPropagation() + + @togglePage() + + if @options.selectable + selector = ".dropdown-content a" + + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content a" + + @dropdown.on "click", selector, (e) -> + self.rowClicked $(@) + + if self.options.clicked + self.options.clicked() + + toggleLoading: -> + $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS + + togglePage: -> + menu = $('.dropdown-menu', @dropdown) + + if menu.hasClass(PAGE_TWO_CLASS) + if @remote + @remote.execute() + + menu.toggleClass PAGE_TWO_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: => + contentHtml = $('.dropdown-content', @dropdown).html() + if @remote && contentHtml is "" + @remote.execute() + + if @options.filterable + @dropdown.find(".dropdown-input-field").focus() + + hidden: => + if @options.filterable + @dropdown.find(".dropdown-input-field").blur().val("") + + if @dropdown.find(".dropdown-toggle-page").length + $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS + + + # 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) -> + selector = '.dropdown-content' + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content" + + $(selector, @dropdown).html html + + # Render the row + renderItem: (data) -> + html = "" + + return "<li class='divider'></li>" if data is "divider" + + 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 + field = @dropdown.parent().find("input[name='#{fieldName}']") + + if el.hasClass(ACTIVE_CLASS) + field.remove() + else + fieldName = @options.fieldName + selectedIndex = el.parent().index() + if @renderedData + selectedObject = @renderedData[selectedIndex] + value = if @options.id then @options.id(selectedObject, el) else selectedObject.id + + if @options.multiSelect + oldValue = field.val() + if oldValue + value = "#{oldValue},#{value}" + else + @dropdown.find(ACTIVE_CLASS).removeClass ACTIVE_CLASS + field.remove() + + # Toggle active class for the tick mark + el.toggleClass "is-active" + + if value + if !field.length + # Create hidden input for form + input = "<input type='hidden' name='#{fieldName}' />" + @dropdown.before input + + @dropdown.parent().find("input[name='#{fieldName}']").val value + + selectFirstRow: -> + selector = '.dropdown-content li:first-child a' + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content li:first-child a" + + # similute a click on the first link + $(selector).trigger "click" + +$.fn.glDropdown = (opts) -> + return @.each -> + new GitLabDropdown @, opts diff --git a/app/assets/javascripts/issue_status_select.js.coffee b/app/assets/javascripts/issue_status_select.js.coffee new file mode 100644 index 00000000000..c5740f27ddd --- /dev/null +++ b/app/assets/javascripts/issue_status_select.js.coffee @@ -0,0 +1,11 @@ +class @IssueStatusSelect + constructor: -> + $('.js-issue-status').each (i, el) -> + fieldName = $(el).data("field-name") + + $(el).glDropdown( + selectable: true + fieldName: fieldName + id: (obj, el) -> + $(el).data("id") + ) diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee new file mode 100644 index 00000000000..5ade2cb66cb --- /dev/null +++ b/app/assets/javascripts/labels_select.js.coffee @@ -0,0 +1,92 @@ +class @LabelsSelect + constructor: -> + $('.js-label-select').each (i, dropdown) -> + projectId = $(dropdown).data('project-id') + labelUrl = $(dropdown).data("labels") + selectedLabel = $(dropdown).data('selected') + if selectedLabel + selectedLabel = selectedLabel.split(",") + newLabelField = $('#new_label_name') + newColorField = $('#new_label_color') + showNo = $(dropdown).data('show-no') + showAny = $(dropdown).data('show-any') + + if newLabelField.length + $('.suggest-colors-dropdown a').on "click", (e) -> + e.preventDefault() + e.stopPropagation() + newColorField.val $(this).data("color") + $('.js-dropdown-label-color-preview') + .css 'background-color', $(this).data("color") + .addClass 'is-active' + + $('.js-new-label-btn').on "click", (e) -> + e.preventDefault() + e.stopPropagation() + + if newLabelField.val() isnt "" && newColorField.val() isnt "" + $('.js-new-label-btn').disable() + + # Create new label with API + Api.newLabel projectId, { + name: newLabelField.val() + color: newColorField.val() + }, (label) -> + $('.js-new-label-btn').enable() + $('.dropdown-menu-back', $(dropdown).parent()).trigger "click" + + $(dropdown).glDropdown( + data: (term, callback) -> + # We have to fetch the JS version of the labels list because there is no + # public facing JSON url for labels + $.ajax( + url: labelUrl + ).done (data) -> + html = $(data) + data = [] + html.find('.label-row a').each -> + data.push( + title: $(@).text().trim() + ) + + if showNo + data.unshift( + id: "0" + title: 'No label' + ) + + if showAny + data.unshift( + title: 'Any label' + ) + + if data.length > 2 + data.splice 2, 0, "divider" + + callback data + renderRow: (label) -> + if $.isArray(selectedLabel) + selected = "" + $.each selectedLabel, (i, selectedLbl) -> + selectedLbl = selectedLbl.trim() + if selected is "" && label.title is selectedLbl + selected = "is-active" + else + selected = if label.title is selectedLabel then "is-active" else "" + + "<li> + <a href='#' class='#{selected}'> + #{label.title} + </a> + </li>" + filterable: true + search: + fields: ['title'] + selectable: true + fieldName: $(dropdown).data('field-name') + id: (label) -> + label.title + clicked: -> + if $(dropdown).hasClass "js-filter-submit" + $(dropdown).parents('form').submit() + ) diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee new file mode 100644 index 00000000000..5e884454a65 --- /dev/null +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -0,0 +1,60 @@ +class @MilestoneSelect + constructor: -> + $('.js-milestone-select').each (i, dropdown) -> + projectId = $(dropdown).data('project-id') + milestonesUrl = $(dropdown).data('milestones') + selectedMilestone = $(dropdown).data('selected') + showNo = $(dropdown).data('show-no') + showAny = $(dropdown).data('show-any') + useId = $(dropdown).data('use-id') + + $(dropdown).glDropdown( + data: (term, callback) -> + $.ajax( + url: milestonesUrl + ).done (data) -> + html = $(data) + data = [] + html.find('.milestone strong a').each -> + link = $(@).attr("href").split("/") + data.push( + id: link[link.length - 1] + title: $(@).text().trim() + ) + + if showNo + data.unshift( + id: "0" + title: 'No Milestone' + ) + + if showAny + data.unshift( + title: 'Any Milestone' + ) + + if data.length > 2 + data.splice 2, 0, "divider" + + callback(data) + filterable: true + search: + fields: ['title'] + selectable: true + fieldName: $(dropdown).data('field-name') + text: (milestone) -> + milestone.title + id: (milestone) -> + if !useId + if milestone.title isnt "Any milestone" + milestone.title + else + "" + else + milestone.id + isSelected: (milestone) -> + milestone.title is selectedMilestone + clicked: -> + if $(dropdown).hasClass "js-filter-submit" + $(dropdown).parents('form').submit() + ) diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index c95ead22e6c..863a4edfad7 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -31,7 +31,7 @@ class @Notes $(document).on "ajax:success", ".js-discussion-note-form", @addDiscussionNote # change note in UI after update - $(document).on "ajax:success", "form.edit_note", @updateNote + $(document).on "ajax:success", "form.edit-note", @updateNote # Edit note link $(document).on "click", ".js-note-edit", @showEditForm @@ -72,7 +72,7 @@ class @Notes cleanBinding: -> $(document).off "ajax:success", ".js-main-target-form" $(document).off "ajax:success", ".js-discussion-note-form" - $(document).off "ajax:success", "form.edit_note" + $(document).off "ajax:success", "form.edit-note" $(document).off "click", ".js-note-edit" $(document).off "click", ".note-edit-cancel" $(document).off "click", ".js-note-delete" @@ -347,22 +347,26 @@ class @Notes note = $(this).closest(".note") note.find(".note-body > .note-text").hide() note.find(".note-header").hide() - base_form = note.find(".note-edit-form") - form = base_form.clone().insertAfter(base_form) - form.addClass('current-note-edit-form gfm-form') - form.find('.div-dropzone').remove() + form = note.find(".note-edit-form") + isNewForm = form.is(':not(.gfm-form)') + if isNewForm + form.addClass('gfm-form') + form.addClass('current-note-edit-form') + form.show() # Show the attachment delete link note.find(".js-note-attachment-delete").show() # Setup markdown form - GitLab.GfmAutoComplete.setup() - new DropzoneInput(form) + if isNewForm + GitLab.GfmAutoComplete.setup() + new DropzoneInput(form) - form.show() textarea = form.find("textarea") textarea.focus() - autosize(textarea) + + if isNewForm + autosize(textarea) # HACK (rspeicher/DouweM): Work around a Chrome 43 bug(?). # The textarea has the correct value, Chrome just won't show it unless we @@ -371,7 +375,8 @@ class @Notes textarea.val "" textarea.val value - disableButtonIfEmptyField textarea, form.find(".js-comment-button") + if isNewForm + disableButtonIfEmptyField textarea, form.find(".js-comment-button") ### Called in response to clicking the edit note link @@ -383,7 +388,9 @@ class @Notes note = $(this).closest(".note") note.find(".note-body > .note-text").show() note.find(".note-header").show() - note.find(".current-note-edit-form").remove() + note.find(".current-note-edit-form") + .removeClass("current-note-edit-form") + .hide() ### Called in response to deleting a note of any kind. diff --git a/app/assets/javascripts/projects_list.js.coffee b/app/assets/javascripts/projects_list.js.coffee index ed5206368ce..e4c4bf3b273 100644 --- a/app/assets/javascripts/projects_list.js.coffee +++ b/app/assets/javascripts/projects_list.js.coffee @@ -2,6 +2,7 @@ init: -> $(".projects-list-filter").off('keyup') this.initSearch() + this.initPagination() initSearch: -> @timer = null @@ -29,3 +30,8 @@ # Change url so if user reload a page - search results are saved history.replaceState {page: project_filter_url}, document.title, project_filter_url dataType: "json" + + initPagination: -> + $('.projects-list-holder .pagination').on('ajax:success', (e, data) -> + $('.projects-list-holder').replaceWith(data.html) + ) diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 9467011799f..987c6f4b8d2 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -3,6 +3,81 @@ class @UsersSelect @usersPath = "/autocomplete/users.json" @userPath = "/autocomplete/users/:id.json" + $('.js-user-search').each (i, dropdown) => + @projectId = $(dropdown).data('project-id') + @showCurrentUser = $(dropdown).data('current-user') + showNullUser = $(dropdown).data('null-user') + showAnyUser = $(dropdown).data('any-user') + firstUser = $(dropdown).data('first-user') + selectedId = $(dropdown).data('selected') + + $(dropdown).glDropdown( + data: (term, callback) => + @users term, (users) => + if term.length is 0 + showDivider = 0 + + if firstUser + # Move current user to the front of the list + for obj, index in users + if obj.username == firstUser + users.splice(index, 1) + users.unshift(obj) + break + + if showNullUser + showDivider += 1 + users.unshift( + name: 'Unassigned', + id: 0 + ) + + if showAnyUser + showDivider += 1 + name = showAnyUser + name = 'Any User' if name == true + anyUser = { + name: name, + id: null + } + users.unshift(anyUser) + + if showDivider + users.splice(showDivider, 0, "divider") + + # Send the data back + callback users + filterable: true + filterRemote: true + search: + fields: ['name', 'username'] + selectable: true + fieldName: $(dropdown).data('field-name') + clicked: -> + if $(dropdown).hasClass "js-filter-submit" + $(dropdown).parents('form').submit() + renderRow: (user) -> + username = if user.username then "@#{user.username}" else "" + avatar = if user.avatar_url then user.avatar_url else false + selected = if user.id is selectedId then "is-active" else "" + img = "" + + if avatar + img = "<img src='#{avatar}' class='avatar avatar-inline' width='30' />" + + "<li> + <a href='#' class='dropdown-menu-user-link #{selected}'> + #{img} + <strong class='dropdown-menu-user-full-name'> + #{user.name} + </strong> + <span class='dropdown-menu-user-username'> + #{username} + </span> + </a> + </li>" + ) + $('.ajax-users-select').each (i, select) => @projectId = $(select).data('project-id') @groupId = $(select).data('group-id') diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index d7e4153ddc0..e6609ac7108 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -28,6 +28,10 @@ border-bottom: 1px solid $border-color; color: $gl-gray; + a { + color: $md-link-color; + } + &.oneline-block { line-height: 42px; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 3dc524ccca4..009d621fc74 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -17,6 +17,47 @@ .dropdown-menu { display: block; } + + .dropdown-menu-toggle { + border-color: $dropdown-toggle-hover-border-color; + + .fa { + color: $dropdown-toggle-hover-icon-color; + } + } +} + +.dropdown-menu-toggle { + position: relative; + width: 160px; + padding: 6px 20px 6px 10px; + background-color: $dropdown-toggle-bg; + color: $dropdown-toggle-color; + font-size: 15px; + text-align: left; + border: 1px solid $dropdown-toggle-border-color; + border-radius: 2px; + outline: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + .fa { + position: absolute; + top: 50%; + right: 6px; + margin-top: -4px; + color: $dropdown-toggle-icon-color; + font-size: 10px; + } + + &:hover, { + border-color: $dropdown-toggle-hover-border-color; + + .fa { + color: $dropdown-toggle-hover-icon-color; + } + } } .dropdown-menu { @@ -24,7 +65,7 @@ position: absolute; top: 100%; left: 0; - z-index: 9999; + z-index: 9; width: 240px; margin-top: 2px; margin-bottom: 0; @@ -36,6 +77,21 @@ border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; + &.is-loading { + .dropdown-content { + display: none; + } + + .dropdown-loading { + display: block; + } + } + + ul { + margin: 0; + padding: 0; + } + li { text-align: left; list-style: none; @@ -61,13 +117,70 @@ white-space: nowrap; overflow: hidden; - &:hover { + &:hover, + &:focus, + &.is-focused { background-color: $dropdown-link-hover-bg; text-decoration: none; + outline: 0; } } } +.dropdown-menu-paging { + .dropdown-page-two, + .dropdown-menu-back { + display: none; + } + + &.is-page-two { + .dropdown-page-one { + display: none; + } + + .dropdown-page-two, + .dropdown-menu-back { + display: block; + } + } +} + +.dropdown-menu-user { + .avatar { + float: left; + width: 30px; + height: 30px; + margin: 0 10px 0 0; + } +} + +.dropdown-menu-user-link { + padding-top: 7px; + padding-bottom: 7px; +} + +.dropdown-menu-user-full-name { + display: block; + margin-bottom: 2px; + font-weight: 600; + line-height: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.dropdown-menu-user-username { + display: block; + line-height: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.dropdown-select { + width: 280px; +} + .dropdown-menu-align-right { left: auto; right: 0; @@ -101,3 +214,130 @@ font-size: 13px; line-height: 22px; } + +.dropdown-title { + position: relative; + margin-bottom: 10px; + padding-left: 30px; + padding-right: 30px; + padding-bottom: 10px; + font-weight: 600; + line-height: 1; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + border-bottom: 1px solid $dropdown-divider-color; + overflow: hidden; +} + +.dropdown-title-button { + position: absolute; + top: -1px; + padding: 0; + color: $dropdown-title-btn-color; + font-size: 14px; + border: 0; + background: none; + outline: 0; + + &:hover { + color: darken($dropdown-title-btn-color, 15%); + } +} + +.dropdown-menu-close { + right: 0; +} + +.dropdown-menu-back { + left: 0; +} + +.dropdown-input { + position: relative; + margin-bottom: 10px; + + .fa { + position: absolute; + top: 10px; + right: 10px; + color: #C7C7C7; + font-size: 12px; + pointer-events: none; + } +} + +.dropdown-input-field { + width: 100%; + padding: 0 7px; + color: $dropdown-input-color; + line-height: 30px; + border: 1px solid $dropdown-divider-color; + border-radius: 2px; + outline: 0; + + &:focus { + color: $dropdown-link-color; + border-color: $dropdown-input-focus-border; + box-shadow: 0 0 4px $dropdown-input-focus-shadow; + + + .fa { + color: $dropdown-link-color; + } + } + + &:hover { + + .fa { + color: $dropdown-link-color; + } + } +} + +.dropdown-content { + max-height: 200px; + overflow-y: scroll; +} + +.dropdown-footer { + padding-top: 10px; + margin-top: 10px; + font-size: 13px; + border-top: 1px solid $dropdown-divider-color; +} + +.dropdown-footer-list { + font-size: 14px; + + a { + padding-left: 10px; + } +} + +.dropdown-loading { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: none; + z-index: 9; + background-color: $dropdown-loading-bg; + font-size: 28px; + + .fa { + position: absolute; + top: 50%; + left: 50%; + margin-top: -14px; + margin-left: -14px; + } +} + +.dropdown-menu-labels { + .label { + position: relative; + width: 30px; + margin-right: 5px; + text-indent: -99999px; + } +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index eab41628677..c431e2b0df3 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -1,5 +1,6 @@ .filter-item { margin-right: 6px; + vertical-align: top; } @media (min-width: 800px) { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 48570abff49..9381cb3281c 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -149,13 +149,13 @@ } &:hover > a.anchor { - $size: 16px; + $size: 14px; position: absolute; right: 100%; top: 50%; - margin-top: -$size/2; - margin-right: 0px; - padding-right: 20px; + margin-top: -11px; + margin-right: 0; + padding-right: 15px; display: inline-block; width: $size; height: $size; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index cc84a5ff932..6561b3de7c1 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -138,3 +138,15 @@ $dropdown-shadow-color: rgba(#000, .1); $dropdown-divider-color: rgba(#000, .1); $dropdown-header-color: #959494; $dropdown-caret-color: #54565B; +$dropdown-title-btn-color: #BFBFBF; +$dropdown-input-color: #C7C7C7; +$dropdown-input-focus-border: rgb(58, 171, 240); +$dropdown-input-focus-shadow: rgba(#000, .2); +$dropdown-loading-bg: rgba(#fff, .6); + +$dropdown-toggle-bg: #fff; +$dropdown-toggle-color: #626262; +$dropdown-toggle-border-color: #EAEAEA; +$dropdown-toggle-hover-border-color: darken($dropdown-toggle-border-color, 15%); +$dropdown-toggle-icon-color: #C4C4C4; +$dropdown-toggle-hover-icon-color: $dropdown-toggle-hover-border-color; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 1c78aafdb87..5ec0966194c 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -7,6 +7,28 @@ display: inline-block; margin-right: 10px; } + + &.suggest-colors-dropdown { + margin-bottom: 5px; + + a { + @include border-radius(0); + width: 36.7px; + margin-right: 0; + margin-bottom: -5px; + } + } +} + +.dropdown-label-color-preview { + display: none; + margin-top: 5px; + width: 100%; + height: 25px; + + &.is-active { + display: block; + } } .label-row { diff --git a/app/assets/stylesheets/pages/snippets.scss b/app/assets/stylesheets/pages/snippets.scss index 7d414ae003d..639d639d5b0 100644 --- a/app/assets/stylesheets/pages/snippets.scss +++ b/app/assets/stylesheets/pages/snippets.scss @@ -28,3 +28,11 @@ border: 1px solid; line-height: 32px; } + +.markdown-snippet-copy { + position: fixed; + top: -10px; + left: -10px; + max-height: 0; + max-width: 0; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fb74919ea23..1f55b18e0b1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -246,6 +246,8 @@ class ApplicationController < ActionController::Base def ldap_security_check if current_user && current_user.requires_ldap_check? + return unless current_user.try_obtain_ldap_lease + unless Gitlab::LDAP::Access.allowed?(current_user) sign_out current_user flash[:alert] = "Access denied for your LDAP account." diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index fc51c3241af..0e8b63872ca 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -8,7 +8,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = filter_projects(@projects) @projects = @projects.includes(:namespace) @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.page(params[:page]).per(PER_PAGE) @last_push = current_user.recent_push @@ -32,7 +32,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = filter_projects(@projects) @projects = @projects.includes(:namespace, :forked_from_project, :tags) @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.page(params[:page]).per(PER_PAGE) @last_push = current_user.recent_push @groups = [] diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 5b811db3068..8271ca87436 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -8,7 +8,7 @@ class Explore::ProjectsController < Explore::ApplicationController @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present? @projects = filter_projects(@projects) @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.includes(:namespace).page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.includes(:namespace).page(params[:page]).per(PER_PAGE) respond_to do |format| format.html @@ -23,7 +23,7 @@ class Explore::ProjectsController < Explore::ApplicationController def trending @projects = TrendingProjectsFinder.new.execute(current_user) @projects = filter_projects(@projects) - @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.page(params[:page]).per(PER_PAGE) respond_to do |format| format.html @@ -39,7 +39,7 @@ class Explore::ProjectsController < Explore::ApplicationController @projects = ProjectsFinder.new.execute(current_user) @projects = filter_projects(@projects) @projects = @projects.reorder('star_count DESC') - @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.page(params[:page]).per(PER_PAGE) respond_to do |format| format.html diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb new file mode 100644 index 00000000000..74f326e0b83 --- /dev/null +++ b/app/helpers/dropdowns_helper.rb @@ -0,0 +1,100 @@ +module DropdownsHelper + def dropdown_tag(toggle_text, options: {}, &block) + content_tag :div, class: "dropdown" do + data_attr = { toggle: "dropdown" } + + if options.has_key?(:data) + data_attr = options[:data].merge(data_attr) + end + + dropdown_output = dropdown_toggle(toggle_text, data_attr, options) + + dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do + output = "" + + if options.has_key?(:title) + output << dropdown_title(options[:title]) + end + + if options.has_key?(:filter) + output << dropdown_filter(options[:placeholder]) + end + + output << content_tag(:div, class: "dropdown-content") do + capture(&block) if block && !options.has_key?(:footer_content) + end + + if block && options.has_key?(:footer_content) + output << content_tag(:div, class: "dropdown-footer") do + capture(&block) + end + end + + output << dropdown_loading + + output.html_safe + end + + dropdown_output.html_safe + end + end + + def dropdown_toggle(toggle_text, data_attr, options) + content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do + output = content_tag(:span, toggle_text, class: "dropdown-toggle-text") + output << icon('chevron-down') + output.html_safe + end + end + + def dropdown_title(title, back: false) + content_tag :div, class: "dropdown-title" do + title_output = "" + + if back + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do + icon('arrow-left') + end + end + + title_output << content_tag(:span, title) + + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do + icon('times') + end + + title_output.html_safe + end + end + + def dropdown_filter(placeholder) + 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 + + def dropdown_content(&block) + content_tag(:div, class: "dropdown-content") do + if block + capture(&block) + end + end + end + + def dropdown_footer(&block) + content_tag(:div, class: "dropdown-footer") do + if block + capture(&block) + end + end + end + + def dropdown_loading + content_tag :div, class: "dropdown-loading" do + icon('spinner spin') + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 426464dee81..65829bec77a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -528,11 +528,11 @@ class Project < ActiveRecord::Base end def ci_services - services.select { |service| service.category == :ci } + services.where(category: :ci) end def ci_service - @ci_service ||= ci_services.find(&:activated?) + @ci_service ||= ci_services.reorder(nil).find_by(active: true) end def jira_tracker? diff --git a/app/models/user.rb b/app/models/user.rb index 3098d49d58a..505a547d8ec 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -612,6 +612,13 @@ class User < ActiveRecord::Base end end + def try_obtain_ldap_lease + # After obtaining this lease LDAP checks will be blocked for 600 seconds + # (10 minutes) for this user. + lease = Gitlab::ExclusiveLease.new("user_ldap_check:#{id}", timeout: 600) + lease.try_obtain + end + def solo_owned_groups @solo_owned_groups ||= owned_groups.select do |group| group.owners == [self] diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index a2c0a858930..d084559abc3 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -19,6 +19,8 @@ %li = link_to 'Buttons', '#buttons' %li + = link_to 'Dropdowns', '#dropdowns' + %li = link_to 'Panels', '#panels' %li = link_to 'Alerts', '#alerts' @@ -180,9 +182,9 @@ .nav-controls = text_field_tag 'sample', nil, class: 'form-control' .dropdown - %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} + %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'} %span Sort by name - %b.caret + = icon('chevron-down') %ul.dropdown-menu %li %a Sort by date @@ -212,6 +214,227 @@ %button.btn.btn-danger{:type => "button"} Danger %button.btn.btn-link{:type => "button"} Link + %h2#dropdowns Dropdowns + + .example + .clearfix + .dropdown.inline.pull-left + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + %ul.dropdown-menu + %li + %a{href: "#"} + Dropdown Option + .dropdown.inline.pull-right + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-align-right + %li + %a{href: "#"} + Dropdown Option + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-selectable + %li + %a.is-active{href: "#"} + Dropdown Option + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Dropdown Title + %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 + %ul + %li + %a.is-active{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li.divider + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + .dropdown-footer + %strong Tip: + If an author is not a member of this project, you can still filter by his name while using the search field. + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown loading + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable.is-loading + .dropdown-title + %span Dropdown Title + %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 + %ul + %li + %a.is-active{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li.divider + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + .dropdown-footer + %strong Tip: + If an author is not a member of this project, you can still filter by his name while using the search field. + .dropdown-loading + = icon('spinner spin') + + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown user + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user + .dropdown-title + %span Dropdown Title + %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 + %ul + %li + %a.dropdown-menu-user-link.is-active{href: "#"} + = link_to_member_avatar(current_user, size: 30) + %strong.dropdown-menu-user-full-name + = current_user.name + .dropdown-menu-user-username + = current_user.to_reference + + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown page 2 + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user.dropdown-menu-paging.is-page-two + .dropdown-page-one + .dropdown-title + %button.dropdown-title-button.dropdown-menu-back{aria: {label: "Go back"}} + = icon('arrow-left') + %span Dropdown Title + %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 + %ul + %li + %a.dropdown-menu-user-link.is-active{href: "#"} + = link_to_member_avatar(current_user, size: 30) + %strong.dropdown-menu-user-full-name + = current_user.name + .dropdown-menu-user-username + = current_user.to_reference + .dropdown-page-two + .dropdown-title + %button.dropdown-title-button.dropdown-menu-back{aria: {label: "Go back"}} + = icon('arrow-left') + %span Create label + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Name new label"} + .dropdown-content + %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 (term, callback) { + Api.projects(term, "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", options: { title: "Go to project", filter: true, placeholder: "Filter projects" }) + %h2#panels Panels .row diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 14ee2263b7d..6a60cfeff76 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,4 +1,4 @@ - unless @project.empty_repo? - if can? current_user, :download_code, @project - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: @ref, format: 'zip'), class: 'btn has_tooltip', rel: 'nofollow', title: "Download ZIP" do + = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: @ref, format: 'zip'), class: 'btn has_tooltip', data: {container: "body"}, rel: 'nofollow', title: "Download ZIP" do = icon('download') diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index b5f076088c7..13e624764d9 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -1,5 +1,5 @@ .note-edit-form - = form_for note, url: namespace_project_note_path(@project.namespace, @project, note), method: :put, remote: true, authenticity_token: true, class: 'js-quick-submit' do |f| + = form_for note, url: namespace_project_note_path(@project.namespace, @project, note), method: :put, remote: true, authenticity_token: true, html: { class: 'edit-note js-quick-submit' } do |f| = note_target_fields(note) = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview' } do = render 'projects/zen', f: f, attr: :note, classes: 'note_text js-note-text js-task-list-field' diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index e55159d996b..c3fbba2ba54 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -7,22 +7,77 @@ class: "check_all_issues left" .issues-other-filters .filter-item.inline - = users_select_tag(:author_id, selected: params[:author_id], - placeholder: 'Author', class: 'trigger-submit', any_user: "Any Author", first_user: true, current_user: true) + - if params[:author_id] + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag("Author", options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author", + placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id" } }) .filter-item.inline - = users_select_tag(:assignee_id, selected: params[:assignee_id], - placeholder: 'Assignee', class: 'trigger-submit', any_user: "Any Assignee", null_user: true, first_user: true, current_user: true) + - if params[:assignee_id] + = hidden_field_tag(:assignee_id, params[:assignee_id]) + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search assignee", data: { any_user: "Any Author", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) .filter-item.inline.milestone-filter - = select_tag('milestone_title', projects_milestones_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Milestone'}) + - if params[:milestone_title] + = hidden_field_tag(:milestone_title, params[:milestone_title]) + = dropdown_tag("Milestone", options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", + placeholder: "Search milestones", footer_content: true, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: (@project.id if @project), milestones: (namespace_project_milestones_path(@project.namespace, @project, :js) if @project) } }) do + - if @project + %ul.dropdown-footer-list + - if can? current_user, :admin_milestone, @project + %li + = link_to new_namespace_project_milestone_path(@project.namespace, @project), title: "New Milestone" do + Create new + %li + = link_to namespace_project_milestones_path(@project.namespace, @project) do + - if can? current_user, :admin_milestone, @project + Manage milestones + - else + View milestones .filter-item.inline.labels-filter - = select_tag('label_name', projects_labels_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Label'}) + - if params[:label_name] + = hidden_field_tag(:label_name, params[:label_name]) + .dropdown + %button.dropdown-menu-toggle.js-label-select.js-filter-submit{type: "button", data: {toggle: "dropdown", field_name: "label_name", show_no: "true", show_any: "true", selected: params[:label_name], project_id: (@project.id if @project), labels: (namespace_project_labels_path(@project.namespace, @project, :js) if @project)}} + %span.dropdown-toggle-text + Label + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-page-one + = dropdown_title("Filter by label") + = dropdown_filter("Search labels") + = dropdown_content + - if @project + = dropdown_footer do + %ul.dropdown-footer-list + - if can? current_user, :admin_label, @project + %li + %a.dropdown-toggle-page{href: "#"} + Create new + %li + = link_to namespace_project_labels_path(@project.namespace, @project) do + - if can? current_user, :admin_label, @project + Manage labels + - else + View labels + - if can? current_user, :admin_label, @project + .dropdown-page-two + = dropdown_title("Create new label", back: true) + = dropdown_content do + %input#new_label_color{type: "hidden"} + %input#new_label_name.dropdown-input-field{type: "text", placeholder: "Name new label"} + .dropdown-label-color-preview.js-dropdown-label-color-preview + .suggest-colors.suggest-colors-dropdown + - suggested_colors.each do |color| + = link_to '#', style: "background-color: #{color}", data: { color: color } do +   + %button.btn.btn-primary.js-new-label-btn{type: "button"} + Create + = dropdown_loading + .dropdown-loading + = icon('spinner spin') .pull-right = render 'shared/sort_dropdown' @@ -31,11 +86,18 @@ .issues_bulk_update.hide = form_tag bulk_update_namespace_project_issues_path(@project.namespace, @project), method: :post do .filter-item.inline - = select_tag('update[state_event]', options_for_select([['Open', 'reopen'], ['Closed', 'close']]), include_blank: true, data: { placeholder: "Status" }) + = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do + %ul + %li + %a{href: "#", data: {id: "reopen"}} Open + %li + %a{href: "#", data: {id: "close"}} Closed .filter-item.inline - = users_select_tag('update[assignee_id]', placeholder: 'Assignee', null_user: true, first_user: true, current_user: true) + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) .filter-item.inline - = select_tag('update[milestone_id]', bulk_update_milestone_options, include_blank: true, data: { placeholder: "Milestone" }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select', filter: true, dropdown_class: "dropdown-menu-selectable", + placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :js), use_id: true } }) = hidden_field_tag 'update[issues_ids]', [] = hidden_field_tag :state_event, params[:state_event] .filter-item.inline @@ -47,6 +109,9 @@ :javascript new UsersSelect(); + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); $('form.filter-form').on('submit', function (event) { event.preventDefault(); Turbolinks.visit(this.action + '&' + $(this).serialize()); diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index e0e41fc4bea..773ce8ac240 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,5 +1,7 @@ - unless @snippet.content.empty? - if markup?(@snippet.file_name) + %textarea.markdown-snippet-copy.blob-content{data: {blob_id: @snippet.id}} + = @snippet.data .file-content.wiki = render_markup(@snippet.file_name, @snippet.data) - else diff --git a/config/application.rb b/config/application.rb index 7fd75ebe69e..d8d1e7b4679 100644 --- a/config/application.rb +++ b/config/application.rb @@ -4,6 +4,7 @@ require 'rails/all' require 'devise' I18n.config.enforce_available_locales = false Bundler.require(:default, Rails.env) +require_relative '../lib/gitlab/redis_config' module Gitlab REDIS_CACHE_NAMESPACE = 'cache:gitlab' @@ -67,22 +68,7 @@ module Gitlab end end - # Use Redis caching across all environments - redis_config_file = Rails.root.join('config', 'resque.yml') - - redis_url_string = if File.exists?(redis_config_file) - YAML.load_file(redis_config_file)[Rails.env] - else - "redis://localhost:6379" - end - - # Redis::Store does not handle Unix sockets well, so let's do it for them - redis_config_hash = Redis::Store::Factory.extract_host_options_from_uri(redis_url_string) - redis_uri = URI.parse(redis_url_string) - if redis_uri.scheme == 'unix' - redis_config_hash[:path] = redis_uri.path - end - + redis_config_hash = Gitlab::RedisConfig.redis_store_options redis_config_hash[:namespace] = REDIS_CACHE_NAMESPACE redis_config_hash[:expires_in] = 2.weeks # Cache should not grow forever config.cache_store = :redis_store, redis_config_hash diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 0fc725842ba..3da5d46be92 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -13,9 +13,12 @@ end if Rails.env.test? Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session" else + redis_config = Gitlab::RedisConfig.redis_store_options + redis_config[:namespace] = 'session:gitlab' + Gitlab::Application.config.session_store( :redis_store, # Using the cookie_store would enable session replay attacks. - servers: Rails.application.config.cache_store[1].merge(namespace: 'session:gitlab'), # re-use the Redis config from the Rails cache store + servers: redis_config, key: '_gitlab_session', secure: Gitlab.config.gitlab.https, httponly: true, diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index dcf6ce74d96..cc83137745a 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,16 +1,9 @@ -# Custom Redis configuration -config_file = Rails.root.join('config', 'resque.yml') - -resque_url = if File.exists?(config_file) - YAML.load_file(config_file)[Rails.env] - else - "redis://localhost:6379" - end +SIDEKIQ_REDIS_NAMESPACE = 'resque:gitlab' Sidekiq.configure_server do |config| config.redis = { - url: resque_url, - namespace: 'resque:gitlab' + url: Gitlab::RedisConfig.url, + namespace: SIDEKIQ_REDIS_NAMESPACE } config.server_middleware do |chain| @@ -36,7 +29,7 @@ end Sidekiq.configure_client do |config| config.redis = { - url: resque_url, - namespace: 'resque:gitlab' + url: Gitlab::RedisConfig.url, + namespace: SIDEKIQ_REDIS_NAMESPACE } end diff --git a/config/mail_room.yml b/config/mail_room.yml index f266a70ee0d..aed55f74eab 100644 --- a/config/mail_room.yml +++ b/config/mail_room.yml @@ -2,6 +2,7 @@ <% require "yaml" require "json" +require_relative "lib/gitlab/redis_config" rails_env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development" @@ -17,13 +18,7 @@ if File.exists?(config_file) config['mailbox'] = "inbox" if config['mailbox'].nil? if config['enabled'] && config['address'] && config['address'].include?('%{key}') - redis_config_file = "config/resque.yml" - redis_url = - if File.exists?(redis_config_file) - YAML.load_file(redis_config_file)[rails_env] - else - "redis://localhost:6379" - end + redis_url = Gitlab::RedisConfig.new(rails_env).url %> - :host: <%= config['host'].to_json %> diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index cbe54e2dc79..d723300f485 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -36,13 +36,22 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps end step 'I click "Authored by me" link' do - select2(current_user.id, from: "#author_id") - select2(nil, from: "#assignee_id") + execute_script('$("#assignee_id").val("")') + execute_script('$(".js-user-search").first().click()') + sleep 1 + execute_script("$('.dropdown-content li:contains(\"#{current_user.to_reference}\") a').click()") + sleep 1 end step 'I click "All" link' do - select2(nil, from: "#author_id") - select2(nil, from: "#assignee_id") + execute_script('$(".js-user-search").first().click()') + sleep 1 + execute_script('$(".js-user-search").first().parent().find("li a").first().click()') + sleep 1 + execute_script('$(".js-user-search").eq(1).click()') + sleep 1 + execute_script('$(".js-user-search").eq(1).parent().find("li a").first().click()') + sleep 1 end def should_see(issue) diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb index 28c8c6b6015..7fc0e444e86 100644 --- a/features/steps/dashboard/merge_requests.rb +++ b/features/steps/dashboard/merge_requests.rb @@ -40,13 +40,22 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps end step 'I click "Authored by me" link' do - select2(current_user.id, from: "#author_id") - select2(nil, from: "#assignee_id") + execute_script('$("#assignee_id").val("")') + execute_script('$(".js-user-search").first().click()') + sleep 0.5 + execute_script("$('.dropdown-content li:contains(\"#{current_user.to_reference}\") a').click()") + sleep 2 end step 'I click "All" link' do - select2(nil, from: "#author_id") - select2(nil, from: "#assignee_id") + execute_script('$(".js-user-search").first().click()') + sleep 0.5 + execute_script('$(".js-user-search").first().parent().find("li a").first().click()') + sleep 2 + execute_script('$(".js-user-search").eq(1).click()') + sleep 0.5 + execute_script('$(".js-user-search").eq(1).parent().find("li a").first().click()') + sleep 2 end def should_see(merge_request) diff --git a/features/steps/project/issues/filter_labels.rb b/features/steps/project/issues/filter_labels.rb index 50bb32429b9..6d50501a722 100644 --- a/features/steps/project/issues/filter_labels.rb +++ b/features/steps/project/issues/filter_labels.rb @@ -29,7 +29,10 @@ class Spinach::Features::ProjectIssuesFilterLabels < Spinach::FeatureSteps end step 'I click link "bug"' do - select2('bug', from: "#label_name") + page.find('.js-label-select').click + sleep 0.5 + execute_script("$('.dropdown-menu-labels li:contains(\"bug\") a').click()") + sleep 2 end step 'I click link "feature"' do diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index d9842ccf95e..383ab4e14da 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -27,7 +27,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click link "Closed"' do - click_link "Closed" + find('.issues-state-filters a', text: "Closed").click end step 'I click button "Unsubscribe"' do @@ -63,14 +63,15 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click "author" dropdown' do - first('#s2id_author_id').click + page.find('.js-author-search').click + sleep 1 end step 'I see current user as the first user' do - expect(page).to have_selector('.user-result', visible: true, count: 3) - users = page.all('.user-name') + expect(page).to have_selector('.dropdown-content', visible: true) + users = page.all('.dropdown-menu-author .dropdown-content li a') expect(users[0].text).to eq 'Any Author' - expect(users[1].text).to eq current_user.name + expect(users[1].text).to eq "#{current_user.name} #{current_user.to_reference}" end step 'I submit new issue "500 error on profile"' do diff --git a/lib/gitlab/exclusive_lease.rb b/lib/gitlab/exclusive_lease.rb new file mode 100644 index 00000000000..2ef50286b1d --- /dev/null +++ b/lib/gitlab/exclusive_lease.rb @@ -0,0 +1,41 @@ +module Gitlab + # This class implements an 'exclusive lease'. We call it a 'lease' + # because it has a set expiry time. We call it 'exclusive' because only + # one caller may obtain a lease for a given key at a time. The + # implementation is intended to work across GitLab processes and across + # servers. It is a 'cheap' alternative to using SQL queries and updates: + # you do not need to change the SQL schema to start using + # ExclusiveLease. + # + # It is important to choose the timeout wisely. If the timeout is very + # high (1 hour) then the throughput of your operation gets very low (at + # most once an hour). If the timeout is lower than how long your + # operation may take then you cannot count on exclusivity. For example, + # if the timeout is 10 seconds and you do an operation which may take 20 + # seconds then two overlapping operations may hold a lease for the same + # key at the same time. + # + class ExclusiveLease + def initialize(key, timeout:) + @key, @timeout = key, timeout + end + + # Try to obtain the lease. Return true on success, + # false if the lease is already taken. + def try_obtain + # Performing a single SET is atomic + !!redis.set(redis_key, '1', nx: true, ex: @timeout) + end + + private + + def redis + # Maybe someday we want to use a connection pool... + @redis ||= Redis.new(url: Gitlab::RedisConfig.url) + end + + def redis_key + "gitlab:exclusive_lease:#{@key}" + end + end +end diff --git a/lib/gitlab/redis_config.rb b/lib/gitlab/redis_config.rb new file mode 100644 index 00000000000..4949c6db539 --- /dev/null +++ b/lib/gitlab/redis_config.rb @@ -0,0 +1,30 @@ +module Gitlab + class RedisConfig + attr_reader :url + + def self.url + new.url + end + + def self.redis_store_options + url = new.url + redis_config_hash = Redis::Store::Factory.extract_host_options_from_uri(url) + # Redis::Store does not handle Unix sockets well, so let's do it for them + redis_uri = URI.parse(url) + if redis_uri.scheme == 'unix' + redis_config_hash[:path] = redis_uri.path + end + redis_config_hash + end + + def initialize(rails_env=nil) + rails_env ||= Rails.env + config_file = File.expand_path('../../../config/resque.yml', __FILE__) + + @url = "redis://localhost:6379" + if File.exists?(config_file) + @url =YAML.load_file(config_file)[rails_env] + end + end + end +end diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index 4885baf9526..d1b42c1f9b9 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -3,7 +3,7 @@ module Gitlab def self.allowed?(user) return false if user.blocked? - if user.requires_ldap_check? + if user.requires_ldap_check? && user.try_obtain_ldap_lease return false unless Gitlab::LDAP::Access.allowed?(user) end diff --git a/lib/tasks/cache.rake b/lib/tasks/cache.rake index f221afcf73a..51e746ef923 100644 --- a/lib/tasks/cache.rake +++ b/lib/tasks/cache.rake @@ -4,16 +4,16 @@ namespace :cache do desc "GitLab | Clear redis cache" task :clear => :environment do - redis_store = Rails.cache.instance_variable_get(:@data) + redis = Redis.new(url: Gitlab::RedisConfig.url) cursor = REDIS_SCAN_START_STOP loop do - cursor, keys = redis_store.scan( + cursor, keys = redis.scan( cursor, match: "#{Gitlab::REDIS_CACHE_NAMESPACE}*", count: CLEAR_BATCH_SIZE ) - redis_store.del(*keys) if keys.any? + redis.del(*keys) if keys.any? break if cursor == REDIS_SCAN_START_STOP end diff --git a/public/logo.svg b/public/logo.svg index c09785cb96f..fc4553137f7 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,26 +1,9 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="210px" height="210px" viewBox="0 0 210 210" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"> - <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch --> - <title>Slice 1</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> - <g id="logo" sketch:type="MSLayerGroup" transform="translate(0.000000, 10.000000)"> - <g id="Page-1" sketch:type="MSShapeGroup"> - <g id="Fill-1-+-Group-24"> - <g id="Group-24"> - <g id="Group"> - <path d="M105.0614,193.655 L105.0614,193.655 L143.7014,74.734 L66.4214,74.734 L105.0614,193.655 L105.0614,193.655 Z" id="Fill-4" fill="#E24329"></path> - <path d="M105.0614,193.6548 L66.4214,74.7338 L12.2684,74.7338 L105.0614,193.6548 L105.0614,193.6548 Z" id="Fill-8" fill="#FC6D26"></path> - <path d="M12.2685,74.7341 L12.2685,74.7341 L0.5265,110.8731 C-0.5445,114.1691 0.6285,117.7801 3.4325,119.8171 L105.0615,193.6551 L12.2685,74.7341 L12.2685,74.7341 Z" id="Fill-12" fill="#FCA326"></path> - <path d="M12.2685,74.7342 L66.4215,74.7342 L43.1485,3.1092 C41.9515,-0.5768 36.7375,-0.5758 35.5405,3.1092 L12.2685,74.7342 L12.2685,74.7342 Z" id="Fill-16" fill="#E24329"></path> - <path d="M105.0614,193.6548 L143.7014,74.7338 L197.8544,74.7338 L105.0614,193.6548 L105.0614,193.6548 Z" id="Fill-18" fill="#FC6D26"></path> - <path d="M197.8544,74.7341 L197.8544,74.7341 L209.5964,110.8731 C210.6674,114.1691 209.4944,117.7801 206.6904,119.8171 L105.0614,193.6551 L197.8544,74.7341 L197.8544,74.7341 Z" id="Fill-20" fill="#FCA326"></path> - <path d="M197.8544,74.7342 L143.7014,74.7342 L166.9744,3.1092 C168.1714,-0.5768 173.3854,-0.5758 174.5824,3.1092 L197.8544,74.7342 L197.8544,74.7342 Z" id="Fill-22" fill="#E24329"></path> - </g> - </g> - </g> - </g> - </g> - </g> -</svg>
\ No newline at end of file +<svg width="210" height="210" viewBox="0 0 210 210" xmlns="http://www.w3.org/2000/svg"> + <path d="M105.0614 203.655l38.64-118.921h-77.28l38.64 118.921z" fill="#e24329"/> + <path d="M105.0614 203.6548l-38.64-118.921h-54.153l92.793 118.921z" fill="#fc6d26"/> + <path d="M12.2685 84.7341l-11.742 36.139c-1.071 3.296.102 6.907 2.906 8.944l101.629 73.838-92.793-118.921z" fill="#fca326"/> + <path d="M12.2685 84.7342h54.153l-23.273-71.625c-1.197-3.686-6.411-3.685-7.608 0l-23.272 71.625z" fill="#e24329"/> + <path d="M105.0614 203.6548l38.64-118.921h54.153l-92.793 118.921z" fill="#fc6d26"/> + <path d="M197.8544 84.7341l11.742 36.139c1.071 3.296-.102 6.907-2.906 8.944l-101.629 73.838 92.793-118.921z" fill="#fca326"/> + <path d="M197.8544 84.7342h-54.153l23.273-71.625c1.197-3.686 6.411-3.685 7.608 0l23.272 71.625z" fill="#e24329"/> +</svg> diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb index 591866b40d4..f6e33f651c4 100644 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ b/spec/features/issues/filter_by_milestone_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' feature 'Issue filtering by Milestone', feature: true do - include Select2Helper - let(:project) { create(:project, :public) } let(:milestone) { create(:milestone, project: project) } @@ -31,6 +29,9 @@ feature 'Issue filtering by Milestone', feature: true do end def filter_by_milestone(title) - select2(title, from: '#milestone_title') + find(".js-milestone-select").click + sleep 0.5 + find(".milestone-filter a", text: title).click + sleep 1 end end diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb index f70214e1122..1b2fd1bab10 100644 --- a/spec/features/merge_requests/filter_by_milestone_spec.rb +++ b/spec/features/merge_requests/filter_by_milestone_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' feature 'Merge Request filtering by Milestone', feature: true do - include Select2Helper - let(:project) { create(:project, :public) } let(:milestone) { create(:milestone, project: project) } @@ -31,6 +29,9 @@ feature 'Merge Request filtering by Milestone', feature: true do end def filter_by_milestone(title) - select2(title, from: '#milestone_title') + find(".js-milestone-select").click + sleep 0.5 + find(".milestone-filter a", text: title).click + sleep 1 end end diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb new file mode 100644 index 00000000000..fbdb7ea34ac --- /dev/null +++ b/spec/lib/gitlab/exclusive_lease_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Gitlab::ExclusiveLease do + it 'cannot obtain twice before the lease has expired' do + lease = Gitlab::ExclusiveLease.new(unique_key, timeout: 3600) + expect(lease.try_obtain).to eq(true) + expect(lease.try_obtain).to eq(false) + end + + it 'can obtain after the lease has expired' do + timeout = 1 + lease = Gitlab::ExclusiveLease.new(unique_key, timeout: timeout) + lease.try_obtain # start the lease + sleep(2 * timeout) # lease should have expired now + expect(lease.try_obtain).to eq(true) + end + + def unique_key + SecureRandom.hex(10) + end +end |