diff options
Diffstat (limited to 'app')
162 files changed, 3115 insertions, 1209 deletions
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/application.js.coffee b/app/assets/javascripts/application.js.coffee index 321da10a009..d415bbd3476 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -42,7 +42,6 @@ #= require jquery.nicescroll #= require_tree . #= require fuzzaldrin-plus -#= require cropper.js window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() @@ -108,6 +107,8 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + bootstrapBreakpoint = bp.getBreakpointSize() + $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") # Click a .js-select-on-focus field, select the contents @@ -220,17 +221,17 @@ $ -> .off 'breakpoint:change' .on 'breakpoint:change', (e, breakpoint) -> if breakpoint is 'sm' or breakpoint is 'xs' - $gutterIcon = $('aside .gutter-toggle').find('i') + $gutterIcon = $('.js-sidebar-toggle').find('i') if $gutterIcon.hasClass('fa-angle-double-right') $gutterIcon.closest('a').trigger('click') $(document) - .off 'click', 'aside .gutter-toggle' - .on 'click', 'aside .gutter-toggle', (e, triggered) -> + .off 'click', '.js-sidebar-toggle' + .on 'click', '.js-sidebar-toggle', (e, triggered) -> e.preventDefault() $this = $(this) $thisIcon = $this.find 'i' - $allGutterToggleIcons = $('.gutter-toggle i') + $allGutterToggleIcons = $('.js-sidebar-toggle i') if $thisIcon.hasClass('fa-angle-double-right') $allGutterToggleIcons .removeClass('fa-angle-double-right') @@ -256,35 +257,14 @@ $ -> $('.right-sidebar') .hasClass('right-sidebar-collapsed'), { path: '/' }) - bootstrapBreakpoint = undefined; - checkBootstrapBreakpoints = -> - if $('.device-xs').is(':visible') - bootstrapBreakpoint = "xs" - else if $('.device-sm').is(':visible') - bootstrapBreakpoint = "sm" - else if $('.device-md').is(':visible') - bootstrapBreakpoint = "md" - else if $('.device-lg').is(':visible') - bootstrapBreakpoint = "lg" - - setBootstrapBreakpoints = -> - if $('.device-xs').length - return - - $("body") - .append('<div class="device-xs visible-xs"></div>'+ - '<div class="device-sm visible-sm"></div>'+ - '<div class="device-md visible-md"></div>'+ - '<div class="device-lg visible-lg"></div>') - checkBootstrapBreakpoints() - fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint - checkBootstrapBreakpoints() + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) checkInitialSidebarSize = -> + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint is "xs" or "sm" $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) @@ -293,6 +273,5 @@ $ -> .on "resize", (e) -> fitSidebarForSize() - setBootstrapBreakpoints() checkInitialSidebarSize() new Aside() diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee index 8f89d3e61a2..03a44874161 100644 --- a/app/assets/javascripts/awards_handler.coffee +++ b/app/assets/javascripts/awards_handler.coffee @@ -1,6 +1,6 @@ class @AwardsHandler constructor: (@post_emoji_url, @noteable_type, @noteable_id, @aliases) -> - $(".add-award").click (event) => + $(".js-add-award").on "click", (event) => event.stopPropagation() event.preventDefault() @@ -9,27 +9,46 @@ class @AwardsHandler $("html").on 'click', (event) -> if !$(event.target).closest(".emoji-menu").length if $(".emoji-menu").is(":visible") - $(".emoji-menu").hide() + $(".emoji-menu").removeClass "is-visible" + + $(".awards") + .off "click" + .on "click", ".js-emoji-btn", @handleClick @renderFrequentlyUsedBlock() - @setupSearch() + + handleClick: (e) -> + e.preventDefault() + emoji = $(this) + .find(".icon") + .data "emoji" + awards_handler.addAward emoji showEmojiMenu: -> if $(".emoji-menu").length - $(".emoji-menu").show() - $("#emoji_search").focus() - else - $.get "/emojis", (response) -> - $(".add-award").after response - $(".emoji-menu").show() + if $(".emoji-menu").is ".is-visible" + $(".emoji-menu").removeClass "is-visible" + $("#emoji_search").blur() + else + $(".emoji-menu").addClass "is-visible" $("#emoji_search").focus() + else + $('.js-add-award').addClass "is-loading" + $.get "/emojis", (response) => + $('.js-add-award').removeClass "is-loading" + $(".js-award-holder").append response + setTimeout => + $(".emoji-menu").addClass "is-visible" + $("#emoji_search").focus() + @setupSearch() + , 200 addAward: (emoji) -> emoji = @normilizeEmojiName(emoji) @postEmoji emoji, => @addAwardToEmojiBar(emoji) - $(".emoji-menu").hide() + $(".emoji-menu").removeClass "is-visible" addAwardToEmojiBar: (emoji) -> @addEmojiToFrequentlyUsedList(emoji) @@ -39,7 +58,7 @@ class @AwardsHandler if @isActive(emoji) @decrementCounter(emoji) else - counter = @findEmojiIcon(emoji).siblings(".counter") + counter = @findEmojiIcon(emoji).siblings(".js-counter") counter.text(parseInt(counter.text()) + 1) counter.parent().addClass("active") @addMeToAuthorList(emoji) @@ -53,7 +72,7 @@ class @AwardsHandler @findEmojiIcon(emoji).parent().hasClass("active") decrementCounter: (emoji) -> - counter = @findEmojiIcon(emoji).siblings(".counter") + counter = @findEmojiIcon(emoji).siblings(".js-counter") emojiIcon = counter.parent() if parseInt(counter.text()) > 1 counter.text(parseInt(counter.text()) - 1) @@ -70,9 +89,13 @@ class @AwardsHandler removeMeFromAuthorList: (emoji) -> award_block = @findEmojiIcon(emoji).parent() - authors = award_block.attr("data-original-title").split(", ") + authors = award_block + .attr("data-original-title") + .split(", ") authors.splice(authors.indexOf("me"),1) - award_block.closest(".award").attr("data-original-title", authors.join(", ")) + award_block + .closest(".js-emoji-btn") + .attr("data-original-title", authors.join(", ")) @resetTooltip(award_block) addMeToAuthorList: (emoji) -> @@ -98,14 +121,18 @@ class @AwardsHandler emojiCssClass = @resolveNameToCssClass(emoji) nodes = [] - nodes.push("<div class='award active' title='me'>") - nodes.push("<div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div>") - nodes.push("<div class='counter'>1</div>") - nodes.push("</div>") - - emoji_node = $(nodes.join("\n")).insertBefore(".awards-controls").find(".emoji-icon").data("emoji", emoji) - - $(".award").tooltip() + nodes.push( + "<button class='btn award-control js-emoji-btn has_tooltip active' title='me'>", + "<div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div>", + "<span class='award-control-text js-counter'>1</span>", + "</button>" + ) + + emoji_node = $(nodes.join("\n")) + .insertBefore(".js-award-holder") + .find(".emoji-icon") + .data("emoji", emoji) + $('.award-control').tooltip() resolveNameToCssClass: (emoji) -> emoji_icon = $(".emoji-menu-content [data-emoji='#{emoji}']") @@ -128,7 +155,7 @@ class @AwardsHandler callback.call() findEmojiIcon: (emoji) -> - $(".award [data-emoji='#{emoji}']") + $(".awards > .js-emoji-btn [data-emoji='#{emoji}']") scrollToAwards: -> $('body, html').animate({ @@ -164,13 +191,13 @@ class @AwardsHandler term = $(ev.target).val() # Clean previous search results - $("ul.emoji-search,h5.emoji-search").remove() + $("ul.emoji-menu-search, h5.emoji-search").remove() if term # Generate a search result block h5 = $("<h5>").text("Search results").addClass("emoji-search") found_emojis = @searchEmojis(term).show() - ul = $("<ul>").addClass("emoji-search").append(found_emojis) + ul = $("<ul>").addClass("emoji-menu-list emoji-menu-search").append(found_emojis) $(".emoji-menu-content ul, .emoji-menu-content h5").hide() $(".emoji-menu-content").append(h5).append(ul) else diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee new file mode 100644 index 00000000000..5457430f921 --- /dev/null +++ b/app/assets/javascripts/breakpoints.coffee @@ -0,0 +1,37 @@ +class @Breakpoints + instance = null; + + class BreakpointInstance + BREAKPOINTS = ["xs", "sm", "md", "lg"] + + constructor: -> + @setup() + + setup: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + return if $(allDeviceSelector.join(",")).length + + # Create all the elements + els = $.map BREAKPOINTS, (breakpoint) -> + "<div class='device-#{breakpoint} visible-#{breakpoint}'></div>" + $("body").append els.join('') + + visibleDevice: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + $(allDeviceSelector.join(",")).filter(":visible") + + getBreakpointSize: -> + $visibleDevice = @visibleDevice + # the page refreshed via turbolinks + if not $visibleDevice().length + @setup() + $visibleDevice = @visibleDevice() + return $visibleDevice.attr("class").split("visible-")[1] + + @get: -> + return instance ?= new BreakpointInstance + +$ => + @bp = Breakpoints.get() diff --git a/app/assets/javascripts/ci/build.coffee b/app/assets/javascripts/ci/build.coffee index 44d5ddb7d95..7afe8bf79e2 100644 --- a/app/assets/javascripts/ci/build.coffee +++ b/app/assets/javascripts/ci/build.coffee @@ -4,6 +4,8 @@ class CiBuild constructor: (build_url, build_status) -> clearInterval(CiBuild.interval) + @initScrollButtonAffix() + if build_status == "running" || build_status == "pending" # # Bind autoscroll button to follow build output @@ -38,4 +40,15 @@ class CiBuild checkAutoscroll: -> $("html,body").scrollTop $("#build-trace").height() if "enabled" is $("#autoscroll-button").data("state") + initScrollButtonAffix: -> + $buildScroll = $('#js-build-scroll') + $body = $('body') + $buildTrace = $('#build-trace') + + $buildScroll.affix( + offset: + bottom: -> + $body.outerHeight() - ($buildTrace.outerHeight() + $buildTrace.offset().top) + ) + @CiBuild = CiBuild diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 54b28f2dd8d..1be86e3b820 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -74,8 +74,9 @@ class Dispatcher shortcut_handler = new ShortcutsNavigation() new TreeView() if $('#tree-slider').length - when 'groups:show' + when 'groups:activity' new Activities() + when 'groups:show' shortcut_handler = new ShortcutsNavigation() when 'groups:group_members:index' new GroupMembers() @@ -103,6 +104,8 @@ class Dispatcher new ProjectFork() when 'projects:artifacts:browse' new BuildArtifacts() + when 'projects:group_links:index' + new GroupsSelect() switch path.first() when 'admin' diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee new file mode 100644 index 00000000000..4f038477755 --- /dev/null +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -0,0 +1,272 @@ +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 !value? + field.remove() + + if @options.multiSelect + oldValue = field.val() + if oldValue + value = "#{oldValue},#{value}" + else + @dropdown.find(ACTIVE_CLASS).removeClass ACTIVE_CLASS + + # 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/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee index 58373ba87a5..8322b4c46ad 100644 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ b/app/assets/javascripts/merge_request_tabs.js.coffee @@ -189,7 +189,7 @@ class @MergeRequestTabs $('.container-fluid').removeClass('container-limited') shrinkView: -> - $gutterIcon = $('.gutter-toggle i') + $gutterIcon = $('.js-sidebar-toggle i') # Wait until listeners are set setTimeout( -> @@ -197,4 +197,3 @@ class @MergeRequestTabs if $gutterIcon.is('.fa-angle-double-right') $gutterIcon.closest('a').trigger('click',[true]) , 0) - 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..75d7f52bbb6 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -30,8 +30,11 @@ class @Notes $(document).on "ajax:success", ".js-main-target-form", @addNote $(document).on "ajax:success", ".js-discussion-note-form", @addDiscussionNote + # catch note ajax errors + $(document).on "ajax:error", ".js-main-target-form", @addNoteError + # 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 @@ -51,6 +54,9 @@ class @Notes $(document).on "ajax:complete", ".js-main-target-form", @reenableTargetFormSubmitButton $(document).on "ajax:success", ".js-main-target-form", @resetMainTargetForm + # reset main target form when clicking discard + $(document).on "click", ".js-note-discard", @resetMainTargetForm + # update the file name when an attachment is selected $(document).on "change", ".js-note-attachment-input", @updateFormAttachment @@ -72,7 +78,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" @@ -85,6 +91,7 @@ class @Notes $(document).off "keyup", ".js-note-text" $(document).off "click", ".js-note-target-reopen" $(document).off "click", ".js-note-target-close" + $(document).off "click", ".js-note-discard" $('.note .js-task-list-container').taskList('disable') $(document).off 'tasklist:changed', '.note .js-task-list-container' @@ -219,7 +226,7 @@ class @Notes Resets text and preview. Resets buttons. ### - resetMainTargetForm: -> + resetMainTargetForm: (e) => form = $(".js-main-target-form") # remove validation errors @@ -231,6 +238,8 @@ class @Notes form.find(".js-note-text").data("autosave").reset() + @updateTargetButtons(e) + reenableTargetFormSubmitButton: -> form = $(".js-main-target-form") @@ -274,8 +283,10 @@ class @Notes form.removeClass "js-new-note-form" form.find('.div-dropzone').remove() + # hide discard button + form.find('.js-note-discard').hide() + # setup preview buttons - form.find(".js-md-write-button, .js-md-preview-button").tooltip placement: "left" previewButton = form.find(".js-md-preview-button") textarea = form.find(".js-note-text") @@ -309,6 +320,10 @@ class @Notes addNote: (xhr, note, status) => @renderNote(note) + addNoteError: (xhr, note, status) => + flash = new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert') + flash.pinTo('.md-area') + ### Called in response to the new note form being submitted @@ -347,22 +362,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 +390,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 +403,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. @@ -462,6 +484,11 @@ class @Notes form.find("#note_line_code").val dataHolder.data("lineCode") form.find("#note_noteable_type").val dataHolder.data("noteableType") form.find("#note_noteable_id").val dataHolder.data("noteableId") + form.find('.js-note-discard') + .show() + .removeClass('js-note-discard') + .addClass('js-close-discussion-note-form') + .text(form.find('.js-close-discussion-note-form').data('cancel-text')) @setupNoteForm form form.find(".js-note-text").focus() form.addClass "js-discussion-note-form" @@ -561,21 +588,52 @@ class @Notes updateCloseButton: (e) => textarea = $(e.target) form = textarea.parents('form') - form.find('.js-note-target-close').text('Close') + closebtn = form.find('.js-note-target-close') + closebtn.text(closebtn.data('original-text')) updateTargetButtons: (e) => textarea = $(e.target) form = textarea.parents('form') + reopenbtn = form.find('.js-note-target-reopen') + closebtn = form.find('.js-note-target-close') + discardbtn = form.find('.js-note-discard') + if textarea.val().trim().length > 0 - form.find('.js-note-target-reopen').text('Comment & reopen') - form.find('.js-note-target-close').text('Comment & close') - form.find('.js-note-target-reopen').addClass('btn-comment-and-reopen') - form.find('.js-note-target-close').addClass('btn-comment-and-close') + reopentext = reopenbtn.data('alternative-text') + closetext = closebtn.data('alternative-text') + + if reopenbtn.text() isnt reopentext + reopenbtn.text(reopentext) + + if closebtn.text() isnt closetext + closebtn.text(closetext) + + if reopenbtn.is(':not(.btn-comment-and-reopen)') + reopenbtn.addClass('btn-comment-and-reopen') + + if closebtn.is(':not(.btn-comment-and-close)') + closebtn.addClass('btn-comment-and-close') + + if discardbtn.is(':hidden') + discardbtn.show() else - form.find('.js-note-target-reopen').text('Reopen') - form.find('.js-note-target-close').text('Close') - form.find('.js-note-target-reopen').removeClass('btn-comment-and-reopen') - form.find('.js-note-target-close').removeClass('btn-comment-and-close') + reopentext = reopenbtn.data('original-text') + closetext = closebtn.data('original-text') + + if reopenbtn.text() isnt reopentext + reopenbtn.text(reopentext) + + if closebtn.text() isnt closetext + closebtn.text(closetext) + + if reopenbtn.is(':not(.btn-comment-and-reopen)') + reopenbtn.removeClass('btn-comment-and-reopen') + + if closebtn.is(':not(.btn-comment-and-close)') + closebtn.removeClass('btn-comment-and-close') + + if discardbtn.is(':visible') + discardbtn.hide() initTaskList: -> @enableTaskList() diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index 9110b732adc..20f87440551 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -4,64 +4,27 @@ class @Profile $('.js-preferences-form').on 'change.preference', 'input[type=radio]', -> $(this).parents('form').submit() - $('.update-username form').on 'ajax:before', -> - $('.loading-gif').show() + $('.update-username').on 'ajax:before', -> + $('.loading-username').show() $(this).find('.update-success').hide() $(this).find('.update-failed').hide() - $('.update-username form').on 'ajax:complete', -> + $('.update-username').on 'ajax:complete', -> + $('.loading-username').hide() $(this).find('.btn-save').enable() $(this).find('.loading-gif').hide() $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - # Avatar management - - $avatarInput = $('.js-user-avatar-input') - $filename = $('.js-avatar-filename') - $modalCrop = $('.modal-profile-crop') - $modalCropImg = $('.modal-profile-crop-image') - - $('.js-choose-user-avatar-button').on "click", -> - $form = $(this).closest("form") - $form.find(".js-user-avatar-input").click() - - $modalCrop.on 'shown.bs.modal', -> - setTimeout ( -> # The cropper must be asynchronously initialized - $modalCropImg.cropper - aspectRatio: 1 - modal: false - scalable: false - rotatable: false - zoomable: false - - crop: (event) -> - ['x', 'y'].forEach (key) -> - $("#user_avatar_crop_#{key}").val(Math.floor(event[key])) - $("#user_avatar_crop_size").val(Math.floor(event.width)) - ), 0 - - $modalCrop.on 'hidden.bs.modal', -> - $modalCropImg.attr('src', '').cropper('destroy') - $avatarInput.val('') - $filename.text($filename.data('label')) - - $('.js-upload-user-avatar').on 'click', -> - $('.edit-user').submit() + $('.js-choose-user-avatar-button').bind "click", -> + form = $(this).closest("form") + form.find(".js-user-avatar-input").click() - $avatarInput.on "change", -> + $('.js-user-avatar-input').bind "change", -> form = $(this).closest("form") filename = $(this).val().replace(/^.*[\\\/]/, '') - $filename.data('label', $filename.text()).text(filename) - - reader = new FileReader - - reader.onload = (event) -> - $modalCrop.modal('show') - $modalCropImg.attr('src', event.target.result) - - fileData = reader.readAsDataURL(this.files[0]) + form.find(".js-avatar-filename").text(filename) $ -> # Extract the SSH Key title from its comment diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index cff309c5972..eea3f5ee910 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,8 +1,7 @@ -$(document).on("click", '.toggle-nav-collapse', (e) -> - e.preventDefault() - collapsed = 'page-sidebar-collapsed' - expanded = 'page-sidebar-expanded' +collapsed = 'page-sidebar-collapsed' +expanded = 'page-sidebar-expanded' +toggleSidebar = -> $('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}") $('header').toggleClass("header-collapsed header-expanded") $('.sidebar-wrapper').toggleClass("sidebar-collapsed sidebar-expanded") @@ -14,4 +13,15 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> niceScrollBars.updateScrollBar(); ), 300 +$(document).on("click", '.toggle-nav-collapse', (e) -> + e.preventDefault() + + toggleSidebar() ) + +$ -> + size = bp.getBreakpointSize() + + if size is "xs" or size is "sm" + if $('.page-with-sidebar').hasClass(expanded) + toggleSidebar() diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee index 7f41616d4e7..084f0e0dc65 100644 --- a/app/assets/javascripts/subscription.js.coffee +++ b/app/assets/javascripts/subscription.js.coffee @@ -1,17 +1,21 @@ class @Subscription - constructor: (url) -> - $(".subscribe-button").unbind("click").click (event)=> - btn = $(event.currentTarget) - action = btn.find("span").text() - current_status = $(".subscription-status").attr("data-status") - btn.prop("disabled", true) - - $.post url, => - btn.prop("disabled", false) - status = if current_status == "subscribed" then "unsubscribed" else "subscribed" - $(".subscription-status").attr("data-status", status) - action = if status == "subscribed" then "Unsubscribe" else "Subscribe" - btn.find("span").text(action) - $(".subscription-status>div").toggleClass("hidden") + constructor: (container) -> + $container = $(container) + @url = $container.attr('data-url') + @subscribe_button = $container.find('.subscribe-button') + @subscription_status = $container.find('.subscription-status') + @subscribe_button.unbind('click').click(@toggleSubscription) - + toggleSubscription: (event) => + btn = $(event.currentTarget) + action = btn.find('span').text() + current_status = @subscription_status.attr('data-status') + btn.prop('disabled', true) + + $.post @url, => + btn.prop('disabled', false) + status = if current_status == 'subscribed' then 'unsubscribed' else 'subscribed' + @subscription_status.attr('data-status', status) + action = if status == 'subscribed' then 'Unsubscribe' else 'Subscribe' + btn.find('span').text(action) + @subscription_status.find('>div').toggleClass('hidden') 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/application.scss b/app/assets/stylesheets/application.scss index e2d590f4df4..2d301d21ab9 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,7 +9,6 @@ *= require_self *= require dropzone/basic *= require cal-heatmap - *= require cropper.css */ /* diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index d7e4153ddc0..d20b77ffae9 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; } @@ -116,6 +120,10 @@ .cover-desc { padding: 0 $gl-padding 3px; color: $gl-text-color; + + &.username:last-child { + padding-bottom: $gl-padding; + } } .cover-controls { @@ -153,3 +161,7 @@ float: right; } } + +.content-block-small { + padding: 10px 0; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 0931090b840..f4608cd80bb 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -12,11 +12,13 @@ .prepend-top-default { margin-top: $gl-padding !important; } .prepend-top-20 { margin-top:20px } .prepend-left-10 { margin-left:10px } -.prepend-left-default { margin-left:$gl-padding } +.prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left:20px } .append-right-5 { margin-right: 5px } .append-right-10 { margin-right:10px } +.append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right:20px } +.append-bottom-0 { margin-bottom:0 } .append-bottom-10 { margin-bottom:10px } .append-bottom-15 { margin-bottom:15px } .append-bottom-20 { margin-bottom:20px } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 3dc524ccca4..5b647fc6176 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: 215px; + 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/files.scss b/app/assets/stylesheets/framework/files.scss index 07907e6e5a6..b034a4882c1 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -169,6 +169,7 @@ */ &.code { padding: 0; + -webkit-overflow-scrolling: auto; // See https://gitlab.com/gitlab-org/gitlab-ce/issues/13987 } } } 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/header.scss b/app/assets/stylesheets/framework/header.scss index e624982c5c9..4c4033e3ae7 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,22 +141,18 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-md-max) { - .header-collapsed { - margin-left: $sidebar_collapsed_width; - } - - .header-expanded { - margin-left: $sidebar_width; - } -} +.header-collapsed { + margin-left: $sidebar_collapsed_width; -@media(min-width: $screen-md-max) { - .header-collapsed { + @media (min-width: $screen-md-min) { @include collapsed-header; } +} + +.header-expanded { + margin-left: $sidebar_collapsed_width; - .header-expanded { + @media (min-width: $screen-md-min) { margin-left: $sidebar_width; } } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 368bbfe5355..1d5000fe388 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -41,12 +41,6 @@ transition: $transition; } -@mixin transform($transform) { - -webkit-transform: $transform; - -ms-transform: $transform; - transform: $transform; -} - /** * Prefilled mixins * Mixins with fixed values diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 7de874c8bcd..b2fbc95e043 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -63,7 +63,7 @@ border-bottom: none; /* Small devices (phones, tablets, 768px and lower) */ - @media (max-width: $screen-sm-min) { + @media (max-width: $screen-sm-max) { width: 100%; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 6b382e4b1b2..26df9acd2ae 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -34,12 +34,12 @@ @media (min-width: $screen-sm-min) { padding-right: $gutter_width; } - + } } .sidebar-wrapper { - z-index: 99; + z-index: 999; background: $background-color; } @@ -203,7 +203,11 @@ } @mixin expanded-sidebar { - padding-left: $sidebar_width; + padding-left: $sidebar_collapsed_width; + + @media (min-width: $screen-md-min) { + padding-left: $sidebar_width; + } &.right-sidebar-collapsed { /* Extra small devices (phones, less than 768px) */ 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..d491d01a3cf 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -34,13 +34,15 @@ $error-exclamation-point: #E62958; $border-radius-default: 3px; $list-title-color: #333333; $list-text-color: #555555; -$profile-settings-link-color: $md-link-color; $btn-transparent-color: #8F8F8F; $ssh-key-icon-color: #8F8F8F; $ssh-key-icon-size: 18px; +$provider-btn-group-border: #E5E5E5; +$provider-btn-not-active-color: #4688F1; + /* * Color schema */ @@ -70,7 +72,7 @@ $orange-light: rgba(252, 109, 38, 0.80); $orange-normal: #E75E40; $orange-dark: #CE5237; -$red-light: #F43263; +$red-light: #F06559; $red-normal: #E52C5A; $red-dark: #D22852; @@ -94,13 +96,17 @@ $border-orange-light: #fc6d26; $border-orange-normal: #CE5237; $border-orange-dark: #C14E35; -$border-red-light: #E52C5A; +$border-red-light: #F24F41; $border-red-normal: #D22852; $border-red-dark: #CA264F; $help-well-bg: #FAFAFA; $help-well-border: #E5E5E5; +$warning-message-bg: #FBF2D9; +$warning-message-color: #9E8E60; +$warning-message-border: #F0E2BB; + /* header */ $light-grey-header: #faf9f9; @@ -138,3 +144,22 @@ $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; + +/* + * Award emoji + */ +$award-emoji-menu-bg: #FFF; +$award-emoji-menu-border: #F1F2F4; +$award-emoji-new-btn-icon-color: #DCDCDC; diff --git a/app/assets/stylesheets/pages/awards.scss b/app/assets/stylesheets/pages/awards.scss index 87dd30f4111..28994e60baa 100644 --- a/app/assets/stylesheets/pages/awards.scss +++ b/app/assets/stylesheets/pages/awards.scss @@ -1,125 +1,133 @@ .awards { - @include clearfix; line-height: 34px; .emoji-icon { width: 20px; height: 20px; - margin: 7px 0 0 5px; } +} - .award { - @include border-radius(5px); - - border: 1px solid; - padding: 0px 10px; - float: left; - margin-right: 5px; - border-color: $border-color; - cursor: pointer; +.emoji-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 3px; + z-index: 1000; + min-width: 160px; + font-size: 14px; + background-color: $award-emoji-menu-bg; + border: 1px solid $award-emoji-menu-border; + border-radius: $border-radius-base; + box-shadow: 0 6px 12px rgba(0,0,0,.175); + pointer-events: none; + opacity: 0; + transform: scale(.2); + transform-origin: 0 -45px; + transition: all .3s cubic-bezier(.87,-.41,.19,1.44); + + &.is-visible { + pointer-events: all; + opacity: 1; + transform: scale(1); + } - &:hover { - background-color: #dce0e5; + .emoji-menu-content { + padding: $gl-padding; + width: 300px; + height: 300px; + overflow-y: scroll; + + input.emoji-search{ + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC"); + background-repeat: no-repeat; + background-position: right 5px center; + background-size: 16px; } + } +} - &.active { - border-color: $border-gray-light; - background-color: $gray-light; - - &:hover { - background-color: #dce0e5; - } +.emoji-menu-list { + list-style: none; + padding-left: 0; + margin-bottom: 0; +} - .counter { - font-weight: bold; - } - } +.emoji-menu-list-item { + padding: 3px; + margin-left: 1px; + margin-right: 1px; +} - .icon { - float: left; - margin-right: 10px; - } +.emoji-menu-btn { + display: block; + cursor: pointer; + width: 30px; + height: 30px; + padding: 0; + background: none; + border: 0; + border-radius: $border-radius-base; + transition: transform .15s cubic-bezier(.3, 0, .2, 2); + + &:hover { + background-color: transparent; + outline: 0; + transform: scale(1.3); + } - .counter { - float: left; - } + &:focus, + &:active { + outline: 0; } - .awards-controls { + .emoji-icon { + display: inline-block; position: relative; - margin-left: 10px; - float: left; + top: 3px; + } +} - .add-award { - font-size: 24px; - color: $gl-gray; - position: relative; - top: 2px; +.award-menu-holder { + display: inline-block; + position: relative; +} - &:hover, - &:link { - text-decoration: none; - } - } +.award-control { + margin-right: 5px; + padding-left: 5px; + padding-right: 5px; + line-height: 20px; + outline: 0; + + &.active, + &:active { + background-color: $white-dark; + box-shadow: none; + outline: 0; + } - .emoji-menu{ - position: absolute; - top: 100%; - left: 0; - z-index: 1000; + &.is-loading { + .award-control-icon { display: none; - float: left; - min-width: 160px; - padding: 5px 0; - margin: 2px 0 0; - font-size: 14px; - text-align: left; - list-style: none; - background-color: #fff; - -webkit-background-clip: padding-box; - background-clip: padding-box; - border: 1px solid #ccc; - border: 1px solid rgba(0,0,0,.15); - border-radius: 4px; - -webkit-box-shadow: 0 6px 12px rgba(0,0,0,.175); - box-shadow: 0 6px 12px rgba(0,0,0,.175); - - .emoji-menu-content { - padding: $gl-padding; - width: 300px; - height: 300px; - overflow-y: scroll; - - h5 { - clear: left; - } - - ul { - list-style-type: none; - margin-left: -20px; - margin-bottom: 20px; - overflow: auto; - } - - input.emoji-search{ - background: image-url("icon-search.png") 240px no-repeat; - } - - li { - cursor: pointer; - width: 30px; - height: 30px; - text-align: center; - float: left; - margin: 3px; - list-decorate: none; - @include border-radius(5px); - - &:hover { - background-color: #ccc; - } - } - } } + + .award-control-icon-loading { + display: block; + } + } + + .icon, + .award-control-icon { + float: left; + margin-right: 5px; + font-size: 20px; + } + + .award-control-icon-loading { + display: none; + } + + .award-control-icon { + color: $award-emoji-new-btn-icon-color; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 3c2997c1d5a..75f298019e3 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -27,10 +27,25 @@ } .scroll-controls { - position: fixed; - bottom: 10px; - left: 250px; - z-index: 100; + &.affix-top { + position: absolute; + top: 10px; + right: 25px; + } + + &.affix-bottom { + position: absolute; + right: 25px; + } + + &.affix { + right: 30px; + bottom: 15px; + + @media (min-width: $screen-md-min) { + right: 26%; + } + } a { display: block; diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index e53d6fc6bdc..c0cc30d33a6 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -90,6 +90,7 @@ position: relative; font-family: $monospace_font; $left: 12px; + overflow: hidden; // See https://gitlab.com/gitlab-org/gitlab-ce/issues/13987 .max-width-marker { width: 72ch; color: rgba(0, 0, 0, 0.0); diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 1c78aafdb87..61ee34b695e 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 { @@ -19,3 +41,7 @@ .color-label { padding: 3px 4px; } + +.label-subscription { + display: inline-block; +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4826b994e37..ecfe0e37c85 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -1,7 +1,6 @@ -.account-page { - fieldset { - margin-bottom: 15px; - padding-bottom: 15px; +.profile-avatar-form-option { + hr { + margin: 10px 0; } } @@ -20,7 +19,7 @@ .account-btn-link, .profile-settings-sidebar a { - color: $profile-settings-link-color; + color: $md-link-color; } .oauth-buttons { @@ -110,42 +109,6 @@ } } -.modal-profile-crop { - .modal-dialog { - width: 500px; - } - - .modal-body { - p { - display: table; - margin: auto; - overflow: hidden; - } - - img { - display: block; - max-width: 400px; - max-height: 400px; - } - - .cropper-bg { - background: none; - } - - .cropper-crop-box { - box-sizing: content-box; - border: 999px solid transparentize(#ccc, 0.5); - @include transform(translate(-999px, -999px)); - } - } -} - -@media (max-width: 520px) { - .modal-profile-crop .modal-dialog { - width: auto; - } -} - .key-list-item { .key-list-item-info { @media (min-width: $screen-sm-min) { @@ -172,6 +135,65 @@ .profile-settings-content { a { - color: $profile-settings-link-color; + color: $md-link-color; + } +} + +.change-username-title { + color: $gl-warning; +} + +.remove-account-title { + color: $gl-danger; +} + +.provider-btn-group { + display: inline-block; + margin-right: 10px; + border: 1px solid $provider-btn-group-border; + border-radius: 3px; + + &:last-child { + margin-right: 0; + } +} + +.provider-btn-image { + display: inline-block; + padding: 5px 10px; + border-right: 1px solid $provider-btn-group-border; + + > img { + width: 20px; + } +} + +.provider-btn { + display: inline-block; + padding: 5px 10px; + margin-left: -3px; + line-height: 22px; + background-color: $gray-light; + + &.not-active { + color: $provider-btn-not-active-color; + } +} + +.profile-settings-message { + line-height: 32px; + color: $warning-message-color; + background-color: $warning-message-bg; + border: 1px solid $warning-message-border; + border-radius: $border-radius-base; +} + +.oauth-applications { + form { + display: inline-block; + } + + .last-heading { + width: 105px; } } 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/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 87f4fb455b8..be192964a93 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -150,7 +150,7 @@ class Admin::UsersController < Admin::ApplicationController :email, :remember_me, :bio, :name, :username, :skype, :linkedin, :twitter, :website_url, :color_scheme_id, :theme_id, :force_random_password, :extern_uid, :provider, :password_expires_at, :avatar, :hide_no_ssh_key, :hide_no_password, - :projects_limit, :can_create_group, :admin, :key_id + :projects_limit, :can_create_group, :admin, :key_id, :external ) end 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/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb new file mode 100644 index 00000000000..0a995c45bdf --- /dev/null +++ b/app/controllers/concerns/continue_params.rb @@ -0,0 +1,13 @@ +module ContinueParams + extend ActiveSupport::Concern + + def continue_params + continue_params = params[:continue] + return nil unless continue_params + + continue_params = continue_params.permit(:to, :notice, :notice_now) + return unless continue_params[:to] && continue_params[:to].start_with?('/') + + continue_params + end +end diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb new file mode 100644 index 00000000000..8a43c0b93c4 --- /dev/null +++ b/app/controllers/concerns/toggle_subscription_action.rb @@ -0,0 +1,17 @@ +module ToggleSubscriptionAction + extend ActiveSupport::Concern + + def toggle_subscription + return unless current_user + + subscribable_resource.toggle_subscription(current_user) + + render nothing: true + end + + private + + def subscribable_resource + raise NotImplementedError + end +end 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/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 5baeb3def08..8243946c852 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -15,7 +15,7 @@ class GroupsController < Groups::ApplicationController # Load group projects before_action :load_projects, except: [:index, :new, :create, :projects, :edit, :update, :autocomplete] - before_action :event_filter, only: [:show, :events] + before_action :event_filter, only: [:activity] layout :determine_layout @@ -44,6 +44,8 @@ class GroupsController < Groups::ApplicationController @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @shared_projects = @group.shared_projects + respond_to do |format| format.html @@ -60,8 +62,10 @@ class GroupsController < Groups::ApplicationController end end - def events + def activity respond_to do |format| + format.html + format.json do load_events pager_json("events/_events", @events.count) @@ -129,7 +133,7 @@ class GroupsController < Groups::ApplicationController end def group_params - params.require(:group).permit(:name, :description, :path, :avatar, :public, :visibility_level) + params.require(:group).permit(:name, :description, :path, :avatar, :public, :visibility_level, :share_with_group_lock) end def load_events diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index dc22101cd5e..d1e4ac10f6c 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -8,7 +8,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController layout 'profile' def index - head :forbidden and return + set_index_vars end def create @@ -20,18 +20,11 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - render :new + set_index_vars + render :index end end - def destroy - if @application.destroy - flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :destroy]) - end - - redirect_to applications_profile_url - end - private def verify_user_oauth_applications_enabled @@ -40,6 +33,17 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController redirect_to applications_profile_url end + def set_index_vars + @applications = current_user.oauth_applications + @authorized_tokens = current_user.oauth_authorized_tokens + @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) + @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + + # Don't overwrite a value possibly set by `create` + @application ||= Doorkeeper::Application.new + end + + # Override Doorkeeper to scope to the current user def set_application @application = current_user.oauth_applications.find(params[:id]) end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a1148961..32fca6b838e 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,13 +8,6 @@ class ProfilesController < Profiles::ApplicationController def show end - def applications - @applications = current_user.oauth_applications - @authorized_tokens = current_user.oauth_authorized_tokens - @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) - @authorized_apps = @authorized_tokens.map(&:application).uniq - [nil] - end - def update user_params.except!(:email) if @user.ldap_user? @@ -65,9 +58,6 @@ class ProfilesController < Profiles::ApplicationController def user_params params.require(:user).permit( - :avatar_crop_x, - :avatar_crop_y, - :avatar_crop_size, :avatar, :bio, :email, diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 7b202f3862f..a1b8632df98 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -1,4 +1,6 @@ class Projects::ForksController < Projects::ApplicationController + include ContinueParams + # Authorize before_action :require_non_empty_project before_action :authorize_download_code! @@ -53,15 +55,4 @@ class Projects::ForksController < Projects::ApplicationController render :error end end - - private - - def continue_params - continue_params = params[:continue] - if continue_params - continue_params.permit(:to, :notice, :notice_now) - else - nil - end - end end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb new file mode 100644 index 00000000000..4159e53bfa9 --- /dev/null +++ b/app/controllers/projects/group_links_controller.rb @@ -0,0 +1,23 @@ +class Projects::GroupLinksController < Projects::ApplicationController + layout 'project_settings' + before_action :authorize_admin_project! + + def index + @group_links = project.project_group_links.all + end + + def create + link = project.project_group_links.new + link.group_id = params[:link_group_id] + link.group_access = params[:link_group_access] + link.save + + redirect_to namespace_project_group_links_path(project.namespace, project) + end + + def destroy + project.project_group_links.find(params[:id]).destroy + + redirect_to namespace_project_group_links_path(project.namespace, project) + end +end diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 196996f1752..7756f0f0ed3 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -1,4 +1,6 @@ class Projects::ImportsController < Projects::ApplicationController + include ContinueParams + # Authorize before_action :authorize_admin_project! before_action :require_no_repo, only: [:new, :create] @@ -44,16 +46,6 @@ class Projects::ImportsController < Projects::ApplicationController private - def continue_params - continue_params = params[:continue] - - if continue_params - continue_params.permit(:to, :notice, :notice_now) - else - nil - end - end - def finished_notice if @project.forked? 'The project was successfully forked.' diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 67faa1e4437..b0a03ee45cc 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,6 +1,8 @@ class Projects::IssuesController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled - before_action :issue, only: [:edit, :update, :show, :toggle_subscription] + before_action :issue, only: [:edit, :update, :show] # Allow read any issue before_action :authorize_read_issue! @@ -110,12 +112,6 @@ class Projects::IssuesController < Projects::ApplicationController redirect_back_or_default(default: { action: 'index' }, options: { notice: "#{result[:count]} issues updated" }) end - def toggle_subscription - @issue.toggle_subscription(current_user) - - render nothing: true - end - def closed_by_merge_requests @closed_by_merge_requests ||= @issue.closed_by_merge_requests(current_user) end @@ -129,6 +125,7 @@ class Projects::IssuesController < Projects::ApplicationController redirect_old end end + alias_method :subscribable_resource, :issue def authorize_update_issue! return render_404 unless can?(current_user, :update_issue, @issue) diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index ecac3c395ec..40d8098690a 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -1,8 +1,12 @@ class Projects::LabelsController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled before_action :label, only: [:edit, :update, :destroy] before_action :authorize_read_label! - before_action :authorize_admin_labels!, except: [:index] + before_action :authorize_admin_labels!, only: [ + :new, :create, :edit, :update, :generate, :destroy + ] respond_to :js, :html @@ -73,8 +77,9 @@ class Projects::LabelsController < Projects::ApplicationController end def label - @label = @project.labels.find(params[:id]) + @label ||= @project.labels.find(params[:id]) end + alias_method :subscribable_resource, :label def authorize_admin_labels! return render_404 unless can?(current_user, :admin_label, @project) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 03ba289eb94..61b82c9db46 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -1,10 +1,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController + include ToggleSubscriptionAction include DiffHelper before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds + :ci_status, :cancel_merge_when_build_succeeds ] before_action :closes_issues, only: [:edit, :update, :show, :diffs, :commits, :builds] before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] @@ -233,12 +234,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end - def toggle_subscription - @merge_request.toggle_subscription(current_user) - - render nothing: true - end - protected def selected_target_project @@ -252,6 +247,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController def merge_request @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end + alias_method :subscribable_resource, :merge_request def closes_issues @closes_issues ||= @merge_request.closes_issues diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 8364fc293b7..e7bddc4a6f1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -27,6 +27,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end @project_member = @project.project_members.new + @project_group_links = @project.project_group_links end def create diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index aea08ecce3e..36f37221c58 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,7 +1,6 @@ class ProjectsController < ApplicationController include ExtractsPath - prepend_before_action :render_go_import, only: [:show] skip_before_action :authenticate_user!, only: [:show, :activity] before_action :project, except: [:new, :create] before_action :repository, except: [:new, :create] @@ -173,10 +172,15 @@ class ProjectsController < ApplicationController def housekeeping ::Projects::HousekeepingService.new(@project).execute - respond_to do |format| - flash[:notice] = "Housekeeping successfully started." - format.html { redirect_to project_path(@project) } - end + redirect_to( + project_path(@project), + notice: "Housekeeping successfully started" + ) + rescue ::Projects::HousekeepingService::LeaseTaken => ex + redirect_to( + edit_project_path(@project), + alert: ex.to_s + ) end def toggle_star @@ -242,16 +246,6 @@ class ProjectsController < ApplicationController end end - def render_go_import - return unless params["go-get"] == "1" - - @namespace = params[:namespace_id] - @id = params[:project_id] || params[:id] - @id = @id.gsub(/\.git\Z/, "") - - render "go_import", layout: false - end - def repo_exists? project.repository_exists? && !project.empty_repo? end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index c88a420b412..19e8c7a92be 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -244,10 +244,17 @@ class IssuableFinder items end + def filter_by_upcoming_milestone? + params[:milestone_title] == '#upcoming' + end + def by_milestone(items) if milestones? if filter_by_no_milestone? items = items.where(milestone_id: [-1, nil]) + elsif filter_by_upcoming_milestone? + upcoming = Milestone.where(project_id: projects).upcoming + items = items.joins(:milestone).where(milestones: { title: upcoming.title }) else items = items.joins(:milestone).where(milestones: { title: params[:milestone_title] }) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 3b4e0362e04..3a5fc5b5907 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -40,21 +40,26 @@ class ProjectsFinder private def group_projects(current_user, group) - if current_user - [ - group_projects_for_user(current_user, group), - group.projects.public_and_internal_only - ] + return [group.projects.public_only] unless current_user + + user_group_projects = [ + group_projects_for_user(current_user, group), + group.shared_projects.visible_to_user(current_user) + ] + if current_user.external? + user_group_projects << group.projects.public_only else - [group.projects.public_only] + user_group_projects << group.projects.public_and_internal_only end end def all_projects(current_user) - if current_user - [current_user.authorized_projects, public_and_internal_projects] + return [public_projects] unless current_user + + if current_user.external? + [current_user.authorized_projects, public_projects] else - [Project.public_only] + [current_user.authorized_projects, public_and_internal_projects] end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 368969c6472..d1b1c61b710 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -72,7 +72,7 @@ module ApplicationHelper if user_or_email.is_a?(User) user = user_or_email else - user = User.find_by(email: user_or_email.try(:downcase)) + user = User.find_by_any_email(user_or_email.try(:downcase)) end if user diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index d8bee21c82e..8b1575d5e0c 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -12,9 +12,13 @@ module CiStatusHelper ci_label_for_status(ci_commit.status) end - def ci_status_with_icon(status) - content_tag :span, class: "ci-status ci-#{status}" do - ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + def ci_status_with_icon(status, target = nil) + content = ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + klass = "ci-status ci-#{status}" + if target + link_to content, target, class: klass + else + content_tag :span, content, class: klass end end @@ -42,12 +46,12 @@ module CiStatusHelper icon(icon_name + ' fw') end - def render_ci_status(ci_commit) + def render_ci_status(ci_commit, tooltip_placement: 'auto left') link_to ci_status_icon(ci_commit), ci_status_path(ci_commit), class: "ci-status-link ci-status-icon-#{ci_commit.status.dasherize}", title: "Build #{ci_status_label(ci_commit)}", - data: { toggle: 'tooltip', placement: 'left' } + data: { toggle: 'tooltip', placement: tooltip_placement } end def no_runners_for_project?(project) 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/helpers/events_helper.rb b/app/helpers/events_helper.rb index e5fcaab9551..37a888d9c60 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -3,7 +3,7 @@ module EventsHelper author = event.author if author - link_to author.name, user_path(author.username) + link_to author.name, user_path(author.username), title: h(author.name) else event.author_name end @@ -159,7 +159,7 @@ module EventsHelper link_to( namespace_project_commit_path(event.project.namespace, event.project, event.note_commit_id, - anchor: dom_id(event.target)), + anchor: dom_id(event.target), title: h(event.target_title)), class: "commit_short_id" ) do "#{event.note_target_type} #{event.note_short_commit_id}" @@ -167,7 +167,7 @@ module EventsHelper elsif event.note_project_snippet? link_to(namespace_project_snippet_path(event.project.namespace, event.project, - event.note_target)) do + event.note_target), title: h(event.project.name)) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 91a3aa371ef..2dfeddf7368 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -31,7 +31,11 @@ module IssuablesHelper end def issuable_state_scope(issuable) - issuable.open? ? :opened : :closed + if issuable.respond_to?(:merged?) && issuable.merged? + :merged + else + issuable.open? ? :opened : :closed + end end end diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 89a054289e8..4455dcd0e20 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -124,6 +124,14 @@ module LabelsHelper options_from_collection_for_select(grouped_labels, 'name', 'title', params[:label_name]) end + def label_subscription_status(label) + label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed' + end + + def label_subscription_toggle_button_text(label) + label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe' + end + # Required for Banzai::Filter::LabelReferenceFilter module_function :render_colored_label, :render_colored_cross_project_label, :text_color_for_bg, :escape_once diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index e3e7daa49c5..e8ac8788d9d 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -59,6 +59,7 @@ module MilestonesHelper 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) + grouped_milestones.unshift(Milestone::Upcoming) options_from_collection_for_select(grouped_milestones, 'name', 'title', params[:milestone_title]) end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c8061fcdc59..b5acb80b720 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -8,7 +8,7 @@ module ProjectsHelper end def link_to_project(project) - link_to [project.namespace.becomes(Namespace), project] do + link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') if project.namespace diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1eb790b1796..494dad0b41e 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -40,7 +40,7 @@ module SearchHelper { label: "help: Rake Tasks Help", url: help_page_path("raketasks", "README") }, { label: "help: SSH Keys Help", url: help_page_path("ssh", "README") }, { label: "help: System Hooks Help", url: help_page_path("system_hooks", "system_hooks") }, - { label: "help: Web Hooks Help", url: help_page_path("web_hooks", "web_hooks") }, + { label: "help: Webhooks Help", url: help_page_path("web_hooks", "web_hooks") }, { label: "help: Workflow Help", url: help_page_path("workflow", "README") }, ] end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 4b745a5b969..07ddc691d85 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo) + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: h(todo.target.title) } end def todo_target_path(todo) diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 4a88cb61132..5f9adb32e00 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -16,7 +16,15 @@ module Emails def closed_issue_email(recipient_id, issue_id, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) + end + + def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id) + + @label_names = label_names + @labels_url = namespace_project_labels_url(@project.namespace, @project) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end @@ -24,20 +32,12 @@ module Emails setup_issue_mail(issue_id, recipient_id) @issue_status = status - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end private - def issue_thread_options(sender_id, recipient_id) - { - from: sender(sender_id), - to: recipient(recipient_id), - subject: subject("#{@issue.title} (##{@issue.iid})") - } - end - def setup_issue_mail(issue_id, recipient_id) @issue = Issue.find(issue_id) @project = @issue.project @@ -45,5 +45,13 @@ module Emails @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) end + + def issue_thread_options(sender_id, recipient_id) + { + from: sender(sender_id), + to: recipient(recipient_id), + subject: subject("#{@issue.title} (##{@issue.iid})") + } + end end end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 325996e2e16..55bb4f65270 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -3,50 +3,43 @@ module Emails def new_merge_request_email(recipient_id, merge_request_id) setup_merge_request_mail(merge_request_id, recipient_id) - mail_new_thread(@merge_request, - from: sender(@merge_request.author_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id)) end def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) + end + + def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + + @label_names = label_names + @labels_url = namespace_project_labels_url(@project.namespace, @project) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - @updated_by = User.find updated_by_user_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @mr_status = status - @updated_by = User.find updated_by_user_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end private @@ -54,11 +47,17 @@ module Emails def setup_merge_request_mail(merge_request_id, recipient_id) @merge_request = MergeRequest.find(merge_request_id) @project = @merge_request.project - @target_url = namespace_project_merge_request_url(@project.namespace, - @project, - @merge_request) + @target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request) @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end + + def merge_request_thread_options(sender_id, recipient_id) + { + from: sender(sender_id), + to: recipient(recipient_id), + subject: subject("#{@merge_request.title} (##{@merge_request.iid})") + } + end end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 3a83b083109..256cbcd73a1 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,10 @@ module Emails end def new_ssh_key_email(key_id) - @key = Key.find(key_id) + @key = Key.find_by_id(key_id) + + return unless @key + @current_user = @user = @key.user @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) diff --git a/app/models/ability.rb b/app/models/ability.rb index bd001ef1545..455ea7bcc69 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -109,23 +109,10 @@ class Ability key = "/user/#{user.id}/project/#{project.id}" RequestStore.store[key] ||= begin - team = project.team + # Push abilities on the users team role + rules.push(*project_team_rules(project.team, user)) - # Rules based on role in project - if team.master?(user) - rules.push(*project_master_rules) - - elsif team.developer?(user) - rules.push(*project_dev_rules) - - elsif team.reporter?(user) - rules.push(*project_report_rules) - - elsif team.guest?(user) - rules.push(*project_guest_rules) - end - - if project.public? || project.internal? + if project.public? || (project.internal? && !user.external?) rules.push(*public_project_rules) # Allow to read builds for internal projects @@ -148,6 +135,19 @@ class Ability end end + def project_team_rules(team, user) + # Rules based on role in project + if team.master?(user) + project_master_rules + elsif team.developer?(user) + project_dev_rules + elsif team.reporter?(user) + project_report_rules + elsif team.guest?(user) + project_guest_rules + end + end + def public_project_rules @public_project_rules ||= project_guest_rules + [ :download_code, @@ -360,7 +360,7 @@ class Ability ] end - if snippet.public? || snippet.internal? + if snippet.public? || (snippet.internal? && !user.external?) rules << :read_personal_snippet end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1227458e525..7d33838044b 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -37,8 +37,6 @@ module Ci class Build < CommitStatus - include Gitlab::Application.routes.url_helpers - LAZY_ATTRIBUTES = ['trace'] belongs_to :runner, class_name: 'Ci::Runner' @@ -128,7 +126,7 @@ module Ci end def retried? - !self.commit.latest_builds_for_ref(self.ref).include?(self) + !self.commit.latest_statuses_for_ref(self.ref).include?(self) end def depends_on_builds @@ -309,22 +307,6 @@ module Ci project.valid_runners_token? token end - def target_url - namespace_project_build_url(project.namespace, project, self) - end - - def cancel_url - if active? - cancel_namespace_project_build_path(project.namespace, project, self) - end - end - - def retry_url - if retryable? - retry_namespace_project_build_path(project.namespace, project, self) - end - end - def can_be_served?(runner) (tag_list - runner.tag_list).empty? end @@ -333,7 +315,7 @@ module Ci project.any_runners? { |runner| runner.active? && runner.online? && can_be_served?(runner) } end - def show_warning? + def stuck? pending? && !any_runners_online? end @@ -348,18 +330,6 @@ module Ci artifacts_file.exists? end - def artifacts_download_url - if artifacts? - download_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - - def artifacts_browse_url - if artifacts_metadata? - browse_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - def artifacts_metadata? artifacts? && artifacts_metadata.exists? end diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index ecbd2078b1d..f4cf7034b14 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -25,8 +25,6 @@ module Ci has_many :builds, class_name: 'Ci::Build' has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest' - scope :ordered, -> { order('CASE WHEN ci_commits.committed_at IS NULL THEN 0 ELSE 1 END', :committed_at, :id) } - validates_presence_of :sha validate :valid_commit_sha @@ -42,16 +40,6 @@ module Ci project.id end - def last_build - builds.order(:id).last - end - - def retry - latest_builds.each do |build| - Ci::Build.retry(build) - end - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -121,12 +109,14 @@ module Ci @latest_statuses ||= statuses.latest.to_a end - def latest_builds - @latest_builds ||= builds.latest.to_a + def latest_statuses_for_ref(ref) + latest_statuses.select { |status| status.ref == ref } end - def latest_builds_for_ref(ref) - latest_builds.select { |build| build.ref == ref } + def matrix_builds(build = nil) + matrix_builds = builds.latest.ordered + matrix_builds = matrix_builds.similar(build) if build + matrix_builds.to_a end def retried @@ -170,7 +160,7 @@ module Ci end def duration - duration_array = latest_statuses.map(&:duration).compact + duration_array = statuses.map(&:duration).compact duration_array.reduce(:+).to_i end @@ -183,16 +173,12 @@ module Ci end def coverage - coverage_array = latest_builds.map(&:coverage).compact + coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 '%.2f' % (coverage_array.reduce(:+) / coverage_array.size) end end - def matrix_for_ref?(ref) - latest_builds_for_ref(ref).size > 1 - end - def config_processor return nil unless ci_yaml_file @config_processor ||= Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) @@ -218,10 +204,6 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - def update_committed! - update!(committed_at: DateTime.now) - end - private def save_yaml_error(error) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index e725a6d468c..90349a07594 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -23,7 +23,7 @@ module Ci LAST_CONTACT_TIME = 5.minutes.ago AVAILABLE_SCOPES = ['specific', 'shared', 'active', 'paused', 'online'] - + has_many :builds, class_name: 'Ci::Build' has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :projects, through: :runner_projects, class_name: '::Project', foreign_key: :gl_project_id @@ -46,9 +46,23 @@ module Ci acts_as_taggable + # Searches for runners matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # This method performs a *partial* match on tokens, thus a query for "a" + # will match any runner where the token contains the letter "a". As a result + # you should *not* use this method for non-admin purposes as otherwise users + # might be able to query a list of all runners. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def self.search(query) - where('LOWER(ci_runners.token) LIKE :query OR LOWER(ci_runners.description) like :query', - query: "%#{query.try(:downcase)}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:token].matches(pattern).or(t[:description].matches(pattern))) end def set_default_values diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 7ef50836322..3b1aa0f5c80 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -125,23 +125,7 @@ class CommitStatus < ActiveRecord::Base end end - def cancel_url - nil - end - - def retry_url - nil - end - - def show_warning? + def stuck? false end - - def artifacts_download_url - nil - end - - def artifacts_browse_url - nil - end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 27b97944e38..86ab84615ba 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -8,6 +8,7 @@ module Issuable extend ActiveSupport::Concern include Participable include Mentionable + include Subscribable include StripAttribute included do @@ -18,7 +19,6 @@ module Issuable has_many :notes, as: :noteable, dependent: :destroy has_many :label_links, as: :target, dependent: :destroy has_many :labels, through: :label_links - has_many :subscriptions, dependent: :destroy, as: :subscribable validates :author, presence: true validates :title, presence: true, length: { within: 0..255 } @@ -61,12 +61,29 @@ module Issuable end module ClassMethods + # Searches for records with a matching title. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - where("LOWER(title) like :query", query: "%#{query.downcase}%") + where(arel_table[:title].matches("%#{query}%")) end + # Searches for records with a matching title or description. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def full_search(query) - where("LOWER(title) like :query OR LOWER(description) like :query", query: "%#{query.downcase}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:title].matches(pattern).or(t[:description].matches(pattern))) end def sort(method) @@ -132,28 +149,10 @@ module Issuable notes.awards.where(note: "thumbsup").count end - def subscribed?(user) - subscription = subscriptions.find_by_user_id(user.id) - - if subscription - return subscription.subscribed - end - + def subscribed_without_subscriptions?(user) participants(user).include?(user) end - def toggle_subscription(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: !subscribed?(user)) - end - - def unsubscribe(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: false) - end - def to_hook_data(user) hook_data = { object_kind: self.class.name.underscore, diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 00000000000..d5a881b2445 --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,44 @@ +# == Subscribable concern +# +# Users can subscribe to these models. +# +# Used by Issue, MergeRequest, Label +# + +module Subscribable + extend ActiveSupport::Concern + + included do + has_many :subscriptions, dependent: :destroy, as: :subscribable + end + + def subscribed?(user) + if subscription = subscriptions.find_by_user_id(user.id) + subscription.subscribed + else + subscribed_without_subscriptions?(user) + end + end + + # Override this method to define custom logic to consider a subscribable as + # subscribed without an explicit subscription record. + def subscribed_without_subscriptions?(user) + false + end + + def subscribers + subscriptions.where(subscribed: true).map(&:user) + end + + def toggle_subscription(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: !subscribed?(user)) + end + + def unsubscribe(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: false) + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 02b9a968dcd..b094a65e3d6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -25,6 +25,8 @@ class Group < Namespace has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members has_many :users, through: :group_members + has_many :project_group_links, dependent: :destroy + has_many :shared_projects, through: :project_group_links, source: :project validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } @@ -35,8 +37,18 @@ class Group < Namespace after_destroy :post_destroy_hook class << self + # Searches for groups matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - where("LOWER(namespaces.name) LIKE :query or LOWER(namespaces.path) LIKE :query", query: "%#{query.downcase}%") + table = Namespace.arel_table + pattern = "%#{query}%" + + where(table[:name].matches(pattern).or(table[:path].matches(pattern))) end def sort(method) diff --git a/app/models/key.rb b/app/models/key.rb index 406a1257b5d..0282ad18139 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -16,6 +16,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include AfterCommitQueue include Sortable belongs_to :user @@ -62,7 +63,7 @@ class Key < ActiveRecord::Base end def notify_user - NotificationService.new.new_key(self) + run_after_commit { NotificationService.new.new_key(self) } end def post_create_hook diff --git a/app/models/label.rb b/app/models/label.rb index 5ff644b8426..f7ffc0b7f36 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -14,6 +14,8 @@ class Label < ActiveRecord::Base include Referable + include Subscribable + # Represents a "No Label" state used for filtering Issues and Merge # Requests that have no label assigned. LabelStruct = Struct.new(:title, :name) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c1e18bb3cc5..188325045e2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -135,7 +135,6 @@ class MergeRequest < ActiveRecord::Base scope :by_branch, ->(branch_name) { where("(source_branch LIKE :branch) OR (target_branch LIKE :branch)", branch: branch_name) } scope :cared, ->(user) { where('assignee_id = :user OR author_id = :user', user: user.id) } scope :by_milestone, ->(milestone) { where(milestone_id: milestone) } - scope :in_projects, ->(project_ids) { where("source_project_id in (:project_ids) OR target_project_id in (:project_ids)", project_ids: project_ids) } scope :of_projects, ->(ids) { where(target_project_id: ids) } scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } @@ -161,6 +160,24 @@ class MergeRequest < ActiveRecord::Base super("merge_requests", /(?<merge_request>\d+)/) end + # Returns all the merge requests from an ActiveRecord:Relation. + # + # This method uses a UNION as it usually operates on the result of + # ProjectsFinder#execute. PostgreSQL in particular doesn't always like queries + # using multiple sub-queries especially when combined with an OR statement. + # UNIONs on the other hand perform much better in these cases. + # + # relation - An ActiveRecord::Relation that returns a list of Projects. + # + # Returns an ActiveRecord::Relation. + def self.in_projects(relation) + source = where(source_project_id: relation).select(:id) + target = where(target_project_id: relation).select(:id) + union = Gitlab::SQL::Union.new([source, target]) + + where("merge_requests.id IN (#{union.to_sql})") + end + def to_reference(from_project = nil) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/models/milestone.rb b/app/models/milestone.rb index e3969f32dd6..374590ba0c5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -19,6 +19,7 @@ class Milestone < ActiveRecord::Base MilestoneStruct = Struct.new(:title, :name, :id) None = MilestoneStruct.new('No Milestone', 'No Milestone', 0) Any = MilestoneStruct.new('Any Milestone', '', -1) + Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) include InternalId include Sortable @@ -58,9 +59,18 @@ class Milestone < ActiveRecord::Base alias_attribute :name, :title class << self + # Searches for milestones matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - query = "%#{query}%" - where("title like ? or description like ?", query, query) + t = arel_table + pattern = "%#{query}%" + + where(t[:title].matches(pattern).or(t[:description].matches(pattern))) end end @@ -72,6 +82,10 @@ class Milestone < ActiveRecord::Base super("milestones", /(?<milestone>\d+)/) end + def self.upcoming + self.where('due_date > ?', Time.now).order(due_date: :asc).first + end + def to_reference(from_project = nil) escaped_title = self.title.gsub("]", "\\]") diff --git a/app/models/namespace.rb b/app/models/namespace.rb index bdb33f37495..55842df1e2d 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -52,8 +52,18 @@ class Namespace < ActiveRecord::Base find_by("lower(path) = :path OR lower(name) = :path", path: path.downcase) end + # Searches for namespaces matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation def search(query) - where("name LIKE :query OR path LIKE :query", query: "%#{query}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:name].matches(pattern).or(t[:path].matches(pattern))) end def clean_path(path) diff --git a/app/models/note.rb b/app/models/note.rb index 3b20d5d22b6..b0c33f2eec5 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -44,6 +44,7 @@ class Note < ActiveRecord::Base delegate :name, :email, to: :author, prefix: true before_validation :set_award! + before_validation :clear_blank_line_code! validates :note, :project, presence: true validates :note, uniqueness: { scope: [:author, :noteable_type, :noteable_id] }, if: ->(n) { n.is_award } @@ -63,7 +64,7 @@ class Note < ActiveRecord::Base scope :nonawards, ->{ where(is_award: false) } scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) } scope :inline, ->{ where("line_code IS NOT NULL") } - scope :not_inline, ->{ where(line_code: [nil, '']) } + scope :not_inline, ->{ where(line_code: nil) } scope :system, ->{ where(system: true) } scope :user, ->{ where(system: false) } scope :common, ->{ where(noteable_type: ["", nil]) } @@ -105,8 +106,18 @@ class Note < ActiveRecord::Base [:discussion, type.try(:underscore), id, line_code].join("-").to_sym end + # Searches for notes matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. def search(query) - where("LOWER(note) like :query", query: "%#{query.downcase}%") + table = arel_table + pattern = "%#{query}%" + + where(table[:note].matches(pattern)) end def grouped_awards @@ -162,26 +173,29 @@ class Note < ActiveRecord::Base Note.where(noteable_id: noteable_id, noteable_type: noteable_type, line_code: line_code).last.try(:diff) end - # Check if such line of code exists in merge request diff - # If exists - its active discussion - # If not - its outdated diff + # Check if this note is part of an "active" discussion + # + # This will always return true for anything except MergeRequest noteables, + # which have special logic. + # + # If the note's current diff cannot be matched in the MergeRequest's current + # diff, it's considered inactive. def active? return true unless self.diff return false unless noteable return @active if defined?(@active) - diffs = noteable.diffs(Commit.max_diff_options) - notable_diff = diffs.find { |d| d.new_path == self.diff.new_path } + noteable_diff = find_noteable_diff - return @active = false if notable_diff.nil? + if noteable_diff + parsed_lines = Gitlab::Diff::Parser.new.parse(noteable_diff.diff.each_line) - parsed_lines = Gitlab::Diff::Parser.new.parse(notable_diff.diff.each_line) - # We cannot use ||= because @active may be false - @active = parsed_lines.any? { |line_obj| line_obj.text == diff_line } - end + @active = parsed_lines.any? { |line_obj| line_obj.text == diff_line } + else + @active = false + end - def outdated? - !active? + @active end def diff_file_index @@ -365,6 +379,16 @@ class Note < ActiveRecord::Base private + def clear_blank_line_code! + self.line_code = nil if self.line_code.blank? + end + + # Find the diff on noteable that matches our own + def find_noteable_diff + diffs = noteable.diffs(Commit.max_diff_options) + diffs.find { |d| d.new_path == self.diff.new_path } + end + def awards_supported? (for_issue? || for_merge_request?) && !for_diff_line? end diff --git a/app/models/project.rb b/app/models/project.rb index ae5f6e2417d..2828385a5f6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -151,6 +151,8 @@ class Project < ActiveRecord::Base has_many :releases, dependent: :destroy has_many :lfs_objects_projects, dependent: :destroy has_many :lfs_objects, through: :lfs_objects_projects + has_many :project_group_links, dependent: :destroy + has_many :invited_groups, through: :project_group_links, source: :group has_many :todos, dependent: :destroy has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" @@ -250,12 +252,6 @@ class Project < ActiveRecord::Base where('projects.last_activity_at < ?', 6.months.ago) end - def publicish(user) - visibility_levels = [Project::PUBLIC] - visibility_levels << Project::INTERNAL if user - where(visibility_level: visibility_levels) - end - def with_push joins(:events).where('events.action = ?', Event::PUSHED) end @@ -264,13 +260,38 @@ class Project < ActiveRecord::Base joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') end + # Searches for a list of projects based on the query given in `query`. + # + # On PostgreSQL this method uses "ILIKE" to perform a case-insensitive + # search. On MySQL a regular "LIKE" is used as it's already + # case-insensitive. + # + # query - The search query as a String. def search(query) - joins(:namespace). - where('LOWER(projects.name) LIKE :query OR - LOWER(projects.path) LIKE :query OR - LOWER(namespaces.name) LIKE :query OR - LOWER(projects.description) LIKE :query', - query: "%#{query.try(:downcase)}%") + ptable = arel_table + ntable = Namespace.arel_table + pattern = "%#{query}%" + + projects = select(:id).where( + ptable[:path].matches(pattern). + or(ptable[:name].matches(pattern)). + or(ptable[:description].matches(pattern)) + ) + + # We explicitly remove any eager loading clauses as they're: + # + # 1. Not needed by this query + # 2. Combined with .joins(:namespace) lead to all columns from the + # projects & namespaces tables being selected, leading to a SQL error + # due to the columns of all UNION'd queries no longer being the same. + namespaces = select(:id). + except(:includes). + joins(:namespace). + where(ntable[:name].matches(pattern)) + + union = Gitlab::SQL::Union.new([projects, namespaces]) + + where("projects.id IN (#{union.to_sql})") end def search_by_visibility(level) @@ -278,7 +299,10 @@ class Project < ActiveRecord::Base end def search_by_title(query) - non_archived.where('LOWER(projects.name) LIKE :query', query: "%#{query.downcase}%") + pattern = "%#{query}%" + table = Project.arel_table + + non_archived.where(table[:name].matches(pattern)) end def find_with_namespace(id) @@ -483,6 +507,7 @@ class Project < ActiveRecord::Base end def external_issue_tracker + return @external_issue_tracker if defined?(@external_issue_tracker) @external_issue_tracker ||= services.issue_trackers.active.without_defaults.first end @@ -526,11 +551,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? @@ -876,6 +901,10 @@ class Project < ActiveRecord::Base jira_tracker? && jira_service.active end + def allowed_to_share_with_group? + !namespace.share_with_group_lock + end + def ci_commit(sha) ci_commits.find_by(sha: sha) end @@ -907,13 +936,13 @@ class Project < ActiveRecord::Base end def valid_runners_token? token - self.runners_token && self.runners_token == token + self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end # TODO (ayufan): For now we use runners_token (backward compatibility) # In 8.4 every build will have its own individual token valid for time of build def valid_build_token? token - self.builds_enabled? && self.runners_token && self.runners_token == token + self.builds_enabled? && self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end def build_coverage_enabled? diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb new file mode 100644 index 00000000000..e52a6bd7c84 --- /dev/null +++ b/app/models/project_group_link.rb @@ -0,0 +1,36 @@ +class ProjectGroupLink < ActiveRecord::Base + GUEST = 10 + REPORTER = 20 + DEVELOPER = 30 + MASTER = 40 + + belongs_to :project + belongs_to :group + + validates :project_id, presence: true + validates :group_id, presence: true + validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" } + validates :group_access, presence: true + validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true + validate :different_group + + def self.access_options + Gitlab::Access.options + end + + def self.default_access + DEVELOPER + end + + def human_access + self.class.access_options.key(self.group_access) + end + + private + + def different_group + if self.group && self.project && self.project.group == self.group + errors.add(:base, "Project cannot be shared with the project it is in.") + end + end +end diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb index e10b5529b42..d9f0849d147 100644 --- a/app/models/project_services/ci_service.rb +++ b/app/models/project_services/ci_service.rb @@ -26,7 +26,7 @@ class CiService < Service default_value_for :category, 'ci' def valid_token?(token) - self.respond_to?(:token) && self.token.present? && self.token == token + self.respond_to?(:token) && self.token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) end def supported_events diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 9629c7e1bb9..70a8bbaba65 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -160,7 +160,27 @@ class ProjectTeam end end - access.max + if project.invited_groups.any? && project.allowed_to_share_with_group? + access << max_invited_level(user_id) + end + + access.compact.max + end + + + def max_invited_level(user_id) + project.project_group_links.map do |group_link| + invited_group = group_link.group + access = invited_group.group_members.find_by(user_id: user_id).try(:access_field) + + # If group member has higher access level we should restrict it + # to max allowed access level + if access && access > group_link.group_access + access = group_link.group_access + end + + access + end.compact.max end private @@ -168,6 +188,35 @@ class ProjectTeam def fetch_members(level = nil) project_members = project.project_members group_members = group ? group.group_members : [] + invited_members = [] + + if project.invited_groups.any? && project.allowed_to_share_with_group? + project.project_group_links.each do |group_link| + invited_group = group_link.group + im = invited_group.group_members + + if level + int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] + + # Skip group members if we ask for masters + # but max group access is developers + next if int_level > group_link.group_access + + # If we ask for developers and max + # group access is developers we need to provide + # both group master, developers as devs + if int_level == group_link.group_access + im.where("access_level >= ?)", group_link.group_access) + else + im.send(level) + end + end + + invited_members << im + end + + invited_members = invited_members.flatten.compact + end if level project_members = project_members.send(level) @@ -175,6 +224,7 @@ class ProjectTeam end user_ids = project_members.pluck(:user_id) + user_ids.push(*invited_members.map(&:user_id)) if invited_members.any? user_ids.push(*group_members.pluck(:user_id)) if group User.where(id: user_ids) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index dd3925c7a7d..b9e835a4486 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -113,12 +113,32 @@ class Snippet < ActiveRecord::Base end class << self + # Searches for snippets with a matching title or file name. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. def search(query) - where('(title LIKE :query OR file_name LIKE :query)', query: "%#{query}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:title].matches(pattern).or(t[:file_name].matches(pattern))) end + # Searches for snippets with matching content. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. def search_code(query) - where('(content LIKE :query)', query: "%#{query}%") + table = Snippet.arel_table + pattern = "%#{query}%" + + where(table[:content].matches(pattern)) end def accessible_to(user) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index dd75d3ab8ba..dd800ce110f 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -15,7 +15,7 @@ class Subscription < ActiveRecord::Base belongs_to :user belongs_to :subscribable, polymorphic: true - validates :user_id, + validates :user_id, uniqueness: { scope: [:subscribable_id, :subscribable_type] }, presence: true end diff --git a/app/models/user.rb b/app/models/user.rb index 3098d49d58a..c011af03591 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,7 @@ # hide_project_limit :boolean default(FALSE) # unlock_token :string # otp_grace_period_started_at :datetime +# external :boolean default(FALSE) # require 'carrierwave/orm/activerecord' @@ -77,6 +78,7 @@ class User < ActiveRecord::Base add_authentication_token_field :authentication_token default_value_for :admin, false + default_value_for :external, false default_value_for :can_create_group, gitlab_config.default_can_create_group default_value_for :can_create_team, false default_value_for :hide_no_ssh_key, false @@ -98,9 +100,6 @@ class User < ActiveRecord::Base # Virtual attribute for authenticating by either username or email attr_accessor :login - # Virtual attributes to define avatar cropping - attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size - # # Relations # @@ -166,11 +165,6 @@ class User < ActiveRecord::Base validate :owns_public_email, if: ->(user) { user.public_email_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } - validates :avatar_crop_x, :avatar_crop_y, :avatar_crop_size, - numericality: { only_integer: true }, - presence: true, - if: ->(user) { user.avatar? && user.avatar_changed? } - before_validation :generate_password, on: :create before_validation :restricted_signup_domains, on: :create before_validation :sanitize_attrs @@ -179,6 +173,7 @@ class User < ActiveRecord::Base after_update :update_emails_with_primary_email, if: ->(user) { user.email_changed? } before_save :ensure_authentication_token + before_save :ensure_external_user_rights after_save :ensure_namespace_correct after_initialize :set_projects_limit after_create :post_create_hook @@ -226,6 +221,7 @@ class User < ActiveRecord::Base # Scopes scope :admins, -> { where(admin: true) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } + scope :external, -> { where(external: true) } scope :active, -> { with_state(:active) } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members)') } @@ -281,13 +277,29 @@ class User < ActiveRecord::Base self.with_two_factor when 'wop' self.without_projects + when 'external' + self.external else self.active end end + # Searches users matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - where("lower(name) LIKE :query OR lower(email) LIKE :query OR lower(username) LIKE :query", query: "%#{query.downcase}%") + table = arel_table + pattern = "%#{query}%" + + where( + table[:name].matches(pattern). + or(table[:email].matches(pattern)). + or(table[:username].matches(pattern)) + ) end def by_login(login) @@ -612,6 +624,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] @@ -811,7 +830,8 @@ class User < ActiveRecord::Base def projects_union Gitlab::SQL::Union.new([personal_projects.select(:id), groups_projects.select(:id), - projects.select(:id)]) + projects.select(:id), + groups.joins(:shared_projects).select(:project_id)]) end def ci_projects_union @@ -827,4 +847,11 @@ class User < ActiveRecord::Base def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end + + def ensure_external_user_rights + return unless self.external? + + self.can_create_group = false + self.projects_limit = 0 + end end diff --git a/app/services/ci/image_for_build_service.rb b/app/services/ci/image_for_build_service.rb index 005a5c4661c..50c95ced8a7 100644 --- a/app/services/ci/image_for_build_service.rb +++ b/app/services/ci/image_for_build_service.rb @@ -3,7 +3,7 @@ module Ci def execute(project, opts) sha = opts[:sha] || ref_sha(project, opts[:ref]) - commit = project.ci_commits.ordered.find_by(sha: sha) + commit = project.ci_commits.find_by(sha: sha) image_name = image_for_commit(commit) image_path = Rails.root.join('public/ci', image_name) diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 31b407efeb1..69d5c42a877 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -33,7 +33,6 @@ class CreateCommitBuildsService unless commit.skip_ci? # Create builds for commit tag = Gitlab::Git.tag_ref?(origin_ref) - commit.update_committed! commit.create_builds(ref, tag, user) end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 93a16e88967..d840ab5e340 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -12,7 +12,7 @@ class GitPushService < BaseService # 1. Creates the push event # 2. Updates merge requests # 3. Recognizes cross-references from commit messages - # 4. Executes the project's web hooks + # 4. Executes the project's webhooks # 5. Executes the project's services # 6. Checks if the project's main language has changed # @@ -49,6 +49,8 @@ class GitPushService < BaseService # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. update_merge_requests + + perform_housekeeping end def update_main_language @@ -73,6 +75,13 @@ class GitPushService < BaseService ProjectCacheWorker.perform_async(@project.id) end + def perform_housekeeping + housekeeping = Projects::HousekeepingService.new(@project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + rescue Projects::HousekeepingService::LeaseTaken + end + def process_default_branch @push_commits = project.repository.commits(params[:newrev]) @@ -80,7 +89,7 @@ class GitPushService < BaseService project.change_head(branch_name) # Set protection on the default branch if configured - if (current_application_settings.default_branch_protection != PROTECTION_NONE) + if current_application_settings.default_branch_protection != PROTECTION_NONE developers_can_push = current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? true : false @project.protected_branches.create({ name: @project.default_branch, developers_can_push: developers_can_push }) end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index ca87dca4a70..18f76d3f650 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -11,7 +11,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, issuable.milestone) end - def create_labels_note(issuable, added_labels, removed_labels) + def create_labels_note(issuable, old_labels) + added_labels = issuable.labels - old_labels + removed_labels = old_labels - issuable.labels + SystemNoteService.change_label( issuable, issuable.project, current_user, added_labels, removed_labels) end @@ -71,20 +74,19 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, options = {}) + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| issuable.previous_changes.include?(attr.to_s) end - old_labels = options[:old_labels] - labels_changed = old_labels && issuable.labels != old_labels + labels_changed = issuable.labels != old_labels attrs_changed || labels_changed end - def handle_common_system_notes(issuable, options = {}) + def handle_common_system_notes(issuable, old_labels: []) if issuable.previous_changes.include?('title') create_title_change_note(issuable, issuable.previous_changes['title'].first) end @@ -93,9 +95,6 @@ class IssuableBaseService < BaseService create_task_status_note(issuable) end - old_labels = options[:old_labels] - if old_labels && (issuable.labels != old_labels) - create_labels_note(issuable, issuable.labels - old_labels, old_labels - issuable.labels) - end + create_labels_note(issuable, old_labels) if issuable.labels != old_labels end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 51ef9dfe610..3563cbaa997 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,8 +4,8 @@ module Issues update(issue) end - def handle_changes(issue, options = {}) - if has_changes?(issue, options) + def handle_changes(issue, old_labels: []) + if has_changes?(issue, old_labels: old_labels) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -23,6 +23,11 @@ module Issues notification_service.reassigned_issue(issue, current_user) todo_service.reassigned_issue(issue, current_user) end + + added_labels = issue.labels - old_labels + if added_labels.present? + notification_service.relabeled_issue(issue, added_labels, current_user) + end end def reopen_service diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6319ad805b6..477c64e7377 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -14,8 +14,8 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, options = {}) - if has_changes?(merge_request, options) + def handle_changes(merge_request, old_labels: []) + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -44,6 +44,15 @@ module MergeRequests merge_request.previous_changes.include?('source_branch') merge_request.mark_as_unchecked end + + added_labels = merge_request.labels - old_labels + if added_labels.present? + notification_service.relabeled_merge_request( + merge_request, + added_labels, + current_user + ) + end end def reopen_service diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ca8a41d93b8..19a6779dea9 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -24,16 +24,17 @@ class NotificationService end end - # When create an issue we should send next emails: + # When create an issue we should send an email to: # # * issue assignee if their notification level is not Disabled # * project team members with notification level higher then Participating + # * watchers of the issue's labels # def new_issue(issue, current_user) new_resource_email(issue, issue.project, 'new_issue_email') end - # When we close an issue we should send next emails: + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled # * issue assignee if their notification level is not Disabled @@ -43,7 +44,7 @@ class NotificationService close_resource_email(issue, issue.project, current_user, 'closed_issue_email') end - # When we reassign an issue we should send next emails: + # When we reassign an issue we should send an email to: # # * issue old assignee if their notification level is not Disabled # * issue new assignee if their notification level is not Disabled @@ -52,16 +53,25 @@ class NotificationService reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email') end + # When we add labels to an issue we should send an email to: + # + # * watchers of the issue's labels + # + def relabeled_issue(issue, added_labels, current_user) + relabeled_resource_email(issue, added_labels, current_user, 'relabeled_issue_email') + end - # When create a merge request we should send next emails: + # When create a merge request we should send an email to: # # * mr assignee if their notification level is not Disabled + # * project team members with notification level higher then Participating + # * watchers of the mr's labels # def new_merge_request(merge_request, current_user) new_resource_email(merge_request, merge_request.target_project, 'new_merge_request_email') end - # When we reassign a merge_request we should send next emails: + # When we reassign a merge_request we should send an email to: # # * merge_request old assignee if their notification level is not Disabled # * merge_request assignee if their notification level is not Disabled @@ -70,6 +80,14 @@ class NotificationService reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email') end + # When we add labels to a merge request we should send an email to: + # + # * watchers of the mr's labels + # + def relabeled_merge_request(merge_request, added_labels, current_user) + relabeled_resource_email(merge_request, added_labels, current_user, 'relabeled_merge_request_email') + end + def close_mr(merge_request, current_user) close_resource_email(merge_request, merge_request.target_project, current_user, 'closed_merge_request_email') end @@ -91,7 +109,8 @@ class NotificationService reopen_resource_email( merge_request, merge_request.target_project, - current_user, 'merge_request_status_email', + current_user, + 'merge_request_status_email', 'reopened' ) end @@ -348,19 +367,23 @@ class NotificationService end def add_subscribed_users(recipients, target) - return recipients unless target.respond_to? :subscriptions + return recipients unless target.respond_to? :subscribers - subscriptions = target.subscriptions + recipients + target.subscribers + end - if subscriptions.any? - recipients + subscriptions.where(subscribed: true).map(&:user) - else - recipients + def add_labels_subscribers(recipients, target, labels: nil) + return recipients unless target.respond_to? :labels + + (labels || target.labels).each do |label| + recipients += label.subscribers end + + recipients end def new_resource_email(target, project, method) - recipients = build_recipients(target, project, target.author) + recipients = build_recipients(target, project, target.author, action: :new) recipients.each do |recipient| mailer.send(method, recipient.id, target.id).deliver_later @@ -392,6 +415,15 @@ class NotificationService end end + def relabeled_resource_email(target, labels, current_user, method) + recipients = build_relabeled_recipients(target, current_user, labels: labels) + label_names = labels.map(&:name) + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, label_names, current_user.id).deliver_later + end + end + def reopen_resource_email(target, project, current_user, method, status) recipients = build_recipients(target, project, current_user) @@ -416,6 +448,11 @@ class NotificationService recipients = reject_muted_users(recipients, project) recipients = add_subscribed_users(recipients, target) + + if action == :new + recipients = add_labels_subscribers(recipients, target) + end + recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) @@ -423,6 +460,13 @@ class NotificationService recipients.uniq end + def build_relabeled_recipients(target, current_user, labels:) + recipients = add_labels_subscribers([], target, labels: labels) + recipients = reject_unsubscribed_users(recipients, target) + recipients.delete(current_user) + recipients.uniq + end + def mailer Notify end diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 0db85ac2142..bccd67d3dbf 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -9,12 +9,39 @@ module Projects class HousekeepingService < BaseService include Gitlab::ShellAdapter + LEASE_TIMEOUT = 3600 + + class LeaseTaken < StandardError + def to_s + "Somebody already triggered housekeeping for this project in the past #{LEASE_TIMEOUT / 60} minutes" + end + end + def initialize(project) @project = project end def execute + raise LeaseTaken if !try_obtain_lease + GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) + ensure + @project.update_column(:pushes_since_gc, 0) + end + + def needed? + @project.pushes_since_gc >= 10 + end + + def increment! + @project.increment!(:pushes_since_gc) + end + + private + + def try_obtain_lease + lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT) + lease.try_obtain end end end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index e904cb6c6fc..e1e94c5cc38 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -10,9 +10,8 @@ module Search group = Group.find_by(id: params[:group_id]) if params[:group_id].present? projects = ProjectsFinder.new.execute(current_user) projects = projects.in_namespace(group.id) if group - project_ids = projects.pluck(:id) - Gitlab::SearchResults.new(project_ids, params[:search]) + Gitlab::SearchResults.new(projects, params[:search]) end end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index f630c0a3790..c08881dce4b 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -7,7 +7,7 @@ module Search end def execute - Gitlab::ProjectSearchResults.new(project.id, + Gitlab::ProjectSearchResults.new(project, params[:search], params[:repository_ref]) end diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index 8ca0877321d..0b3e713e220 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -7,8 +7,9 @@ module Search end def execute - snippet_ids = Snippet.accessible_to(current_user).pluck(:id) - Gitlab::SnippetSearchResults.new(snippet_ids, params[:search]) + snippets = Snippet.accessible_to(current_user) + + Gitlab::SnippetSearchResults.new(snippets, params[:search]) end end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 2c72df44ff0..6135c3ad96f 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -2,22 +2,11 @@ class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper - include CarrierWave::MiniMagick storage :file after :store, :reset_events_cache - process :cropper - - def cropper - return unless model.respond_to?(:avatar_crop_size) && model.valid? - - manipulate! do |img| - img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}" - end - end - def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index 34d955568f2..588ad767426 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -4,13 +4,13 @@ = ci_status_with_icon(build.status) %td.build-link - - if can?(current_user, :read_build, project) && build.target_url - = link_to build.target_url do + - if can?(current_user, :read_build, build.project) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do %strong Build ##{build.id} - else %strong Build ##{build.id} - - if build.show_warning? + - if build.stuck? %i.fa.fa-warning.text-warning %td @@ -18,11 +18,11 @@ = link_to project.name_with_namespace, admin_namespace_project_path(project.namespace, project), class: "monospace" %td - = link_to build.short_sha, namespace_project_commit_path(project.namespace, project, build.sha), class: "monospace" + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" %td - if build.ref - = link_to build.ref, namespace_project_commits_path(project.namespace, project, build.ref) + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) - else .light none @@ -61,13 +61,12 @@ %td .pull-right - if can?(current_user, :read_build, project) && build.artifacts? - = link_to build.artifacts_download_url, title: 'Download artifacts' do + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do %i.fa.fa-download - if can?(current_user, :update_build, build.project) - if build.active? - - if build.cancel_url - = link_to build.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && build.retry_url - = link_to build.retry_url, method: :post, title: 'Retry' do + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do %i.fa.fa-repeat diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f7fd156b84a..264fa1bf0cd 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -50,6 +50,22 @@ .panel-footer = paginate @projects, param_name: 'projects_page', theme: 'gitlab' + - if @group.shared_projects.any? + .panel.panel-default + .panel-heading + Projects shared with #{@group.name} + %span.badge + #{@group.shared_projects.count} + %ul.well-list + - @group.shared_projects.sort_by(&:name).each do |project| + %li + %strong + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + %span.label.label-gray + = repository_size(project) + %span.pull-right.light + %span.monospace= project.path_with_namespace + ".git" + .col-md-6 - if can?(current_user, :admin_group_member, @group) .panel.panel-default diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index e18dd9bc905..d2527ede995 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -58,9 +58,15 @@ = f.label :admin, class: 'control-label' - if current_user == @user .col-sm-10= f.check_box :admin, disabled: true - .col-sm-10 You cannot remove your own admin rights + .col-sm-10 You cannot remove your own admin rights. - else .col-sm-10= f.check_box :admin + + .form-group + = f.label :external, class: 'control-label' + .col-sm-10= f.check_box :external + .col-sm-10 External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups. + %fieldset %legend Profile .form-group diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index b6b1168bd37..0ee8dc962b9 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -19,6 +19,10 @@ = link_to admin_users_path(filter: 'two_factor_disabled') do 2FA Disabled %small.badge= number_with_delimiter(User.without_two_factor.count) + %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} + = link_to admin_users_path(filter: 'external') do + External + %small.badge= number_with_delimiter(User.external.count) %li{class: "#{'active' if params[:filter] == "blocked"}"} = link_to admin_users_path(filter: "blocked") do Blocked @@ -70,12 +74,14 @@ %li .list-item-name - if user.blocked? - %i.fa.fa-lock.cred + = icon("lock", class: "cred") - else - %i.fa.fa-user.cgreen + = icon("user", class: "cgreen") = link_to user.name, [:admin, user] - if user.admin? %strong.cred (Admin) + - if user.external? + %strong.cred (External) - if user == current_user %span.cred It's you! .pull-right diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 2bdbae19588..d37489bebea 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -48,6 +48,10 @@ Disabled %li + %span.light External User: + %strong + = @user.external? ? "Yes" : "No" + %li %span.light Can create groups: %strong = @user.can_create_group ? "Yes" : "No" diff --git a/app/views/ci/commits/_commit.html.haml b/app/views/ci/commits/_commit.html.haml deleted file mode 100644 index 11163813f3e..00000000000 --- a/app/views/ci/commits/_commit.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -%tr.build - %td.status - = ci_status_with_icon(commit.status) - - if commit.running? - · - = commit.stage - - - %td.build-link - = link_to ci_status_path(commit) do - %strong #{commit.short_sha} - - %td.build-message - %span= truncate_first_line(commit.git_commit_message) - - %td.build-branch - - unless @ref - %span - - commit.refs.each do |ref| - = link_to truncate(ref, length: 25), ci_project_path(@project, ref: ref) - - %td.duration - - if commit.duration > 0 - #{time_interval_in_words commit.duration} - - %td.timestamp - - if commit.finished_at - %span #{time_ago_in_words commit.finished_at} ago - - - if commit.coverage - %td.coverage - #{commit.coverage}% diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index c3efa7727b1..d54c7cad7be 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,4 +1,4 @@ -- publicish_project_count = Project.publicish(current_user).count +- publicish_project_count = ProjectsFinder.new.execute(current_user).count %h3.page-title Welcome to GitLab! %p.light Self hosted Git management application. %hr @@ -18,7 +18,7 @@ - if current_user.can_create_project? .link_holder = link_to new_project_path, class: "btn btn-new" do - %i.fa.fa-plus + = icon('plus') New Project - if current_user.can_create_group? diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml index 6a5c917049d..001a711b1dd 100644 --- a/app/views/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/doorkeeper/applications/_delete_form.html.haml @@ -1,4 +1,10 @@ - submit_btn_css ||= 'btn btn-link btn-remove btn-sm' = form_tag oauth_application_path(application) do %input{:name => "_method", :type => "hidden", :value => "delete"}/ - = submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css
\ No newline at end of file + - if defined? small + = button_tag type: "submit", class: "btn btn-transparent", data: { confirm: "Are you sure?" } do + %span.sr-only + Destroy + = icon('trash') + - else + = submit_tag 'Destroy', data: { confirm: "Are you sure?" }, class: submit_btn_css diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 98a61ab211b..906b0676150 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f| += form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f| - if application.errors.any? .alert.alert-danger %ul @@ -6,25 +6,20 @@ %li= msg .form-group - = f.label :name, class: 'control-label' - - .col-sm-10 - = f.text_field :name, class: 'form-control', required: true + = f.label :name, class: 'label-light' + = f.text_field :name, class: 'form-control', required: true .form-group - = f.label :redirect_uri, class: 'control-label' - - .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control', required: true + = f.label :redirect_uri, class: 'label-light' + = f.text_area :redirect_uri, class: 'form-control', required: true + %span.help-block + Use one line per URI + - if Doorkeeper.configuration.native_redirect_uri %span.help-block - Use one line per URI - - if Doorkeeper.configuration.native_redirect_uri - %span.help-block - Use - %code= Doorkeeper.configuration.native_redirect_uri - for local tests + Use + %code= Doorkeeper.configuration.native_redirect_uri + for local tests - .form-actions - = f.submit 'Submit', class: "btn btn-create" - = link_to "Cancel", applications_profile_path, class: "btn btn-cancel" + .prepend-top-default + = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index ba4c5b86efb..ea0b66c932b 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,19 +1,83 @@ - page_title "Applications" -%h3.page-title Your applications -%p= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' +- header_title page_title, applications_profile_path -.table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td= application.redirect_uri - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link' - %td= render 'delete_form', application: application +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + - if user_oauth_applications? + Manage applications that can use GitLab as an OAuth provider, + and applications that you've authorized to use your account. + - else + Manage applications that you've authorized to use your account. + .col-lg-9 + - if user_oauth_applications? + %h5.prepend-top-0 + Add new application + = render 'form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th.last-heading + %tbody + - @applications.each do |application| + %tr{id: "application_#{application.id}"} + %td= link_to application.name, oauth_application_path(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td + = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + %span.sr-only + Edit + = icon('pencil') + = render 'delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20.append-bottom-default + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) + + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Authorized At + %th Scope + %th + %tbody + - @authorized_apps.each do |app| + - token = app.authorized_tokens.order('created_at desc').first + %tr{id: "application_#{app.id}"} + %td= app.name + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', application: app + - @authorized_anonymous_tokens.each do |token| + %tr + %td + Anonymous + %div.help-block + %em Authorization was granted by entering your username and password in the application. + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', token: token + - else + .profile-settings-message.text-center + You don't have any authorized applications diff --git a/app/views/emojis/index.html.haml b/app/views/emojis/index.html.haml index b66e513e4d2..3443a8e2307 100644 --- a/app/views/emojis/index.html.haml +++ b/app/views/emojis/index.html.haml @@ -2,8 +2,10 @@ .emoji-menu-content = text_field_tag :emoji_search, "", class: "emoji-search search-input form-control" - AwardEmoji.emoji_by_category.each do |category, emojis| - %h5= AwardEmoji::CATEGORIES[category] - %ul + %h5.emoji-menu-title + = AwardEmoji::CATEGORIES[category] + %ul.clearfix.emoji-menu-list - emojis.each do |emoji| - %li - = emoji_icon(emoji["name"], emoji["unicode"], emoji["aliases"])
\ No newline at end of file + %li.pull-left.text-center.emoji-menu-list-item + %button.emoji-menu-btn.text-center.js-emoji-btn{type: "button"} + = emoji_icon(emoji["name"], emoji["unicode"], emoji["aliases"]) diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 4ba8b84fd92..dce4081288c 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '' + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index abea86b026a..5753158c24d 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -3,7 +3,7 @@ .event-last-push .event-last-push-text %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do %strong= event.ref_name %span at %strong= link_to_project event.project diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 8bed5cdb9cc..235bd46107e 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -5,7 +5,7 @@ %strong= event.ref_name - else %strong - = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.target_title) at = link_to_project event.project diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml new file mode 100644 index 00000000000..dc76599b776 --- /dev/null +++ b/app/views/groups/_activities.html.haml @@ -0,0 +1,12 @@ +.hidden-xs + = render "events/event_last_push", event: @last_push + +.nav-block + - if current_user + .controls + = link_to dashboard_projects_path(:atom, { private_token: current_user.private_token }), class: 'btn rss-btn' do + %i.fa.fa-rss + = render 'shared/event_filter' + +.content_list += spinner diff --git a/app/views/groups/_projects.html.haml b/app/views/groups/_projects.html.haml index 7cd8e9bea46..cca7dc27b1c 100644 --- a/app/views/groups/_projects.html.haml +++ b/app/views/groups/_projects.html.haml @@ -1,12 +1 @@ -.top-area - .nav-controls - = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - - if @projects.present? - = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false - = render 'shared/projects/dropdown' - - if can? current_user, :create_projects, @group - = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do - = icon('plus') - New Project - -= render 'shared/projects/list', projects: @projects, stars: false, skip_namespace: true += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: true diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml new file mode 100644 index 00000000000..b1694c919d0 --- /dev/null +++ b/app/views/groups/_shared_projects.html.haml @@ -0,0 +1 @@ += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: false diff --git a/app/views/groups/activity.html.haml b/app/views/groups/activity.html.haml new file mode 100644 index 00000000000..f73e1d9e865 --- /dev/null +++ b/app/views/groups/activity.html.haml @@ -0,0 +1,9 @@ += content_for :meta_tags do + - if current_user + = auto_discovery_link_tag(:atom, group_url(@group, format: :atom, private_token: current_user.private_token), title: "#{@group.name} activity") + +- page_title "Activity" +- header_title group_title(@group, "Activity", activity_group_path(@group)) + +%section.activities + = render 'activities' diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index ea223d2209f..ea5a0358392 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -25,6 +25,16 @@ = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + .form-group + %hr + = f.label :share_with_group_lock, class: 'control-label' do + Share with group lock + .col-sm-10 + .checkbox + = f.check_box :share_with_group_lock + %span.descr Prevent sharing a project with another group within this group + + .form-actions = f.submit 'Save group', class: "btn btn-save" diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 641191edf05..2653a03059f 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -38,20 +38,32 @@ - if can?(current_user, :read_group, @group) %div{ class: container_class } - .tab-content - .tab-pane.active#activity - .activity-filter-block - - if current_user - = render "events/event_last_push", event: @last_push - - = render 'shared/event_filter' - - .content_list{data: {href: events_group_path}} - = spinner + .top-area + %ul.nav-links + %li.active + = link_to "#projects", 'data-toggle' => 'tab' do + All Projects + - if @shared_projects.present? + %li + = link_to "#shared", 'data-toggle' => 'tab' do + Shared Projects + .nav-controls + = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| + = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false + = render 'shared/projects/dropdown' + - if can? current_user, :create_projects, @group + = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do + = icon('plus') + New Project - .tab-pane#projects + .tab-content + .tab-pane.active#projects = render "projects", projects: @projects + - if @shared_projects.present? + .tab-pane#shared + = render "shared_projects", projects: @shared_projects + - else %p.nav-links.no-top No projects to show 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/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index e5e2a59eaed..59411ae1da1 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -9,10 +9,15 @@ = nav_link(path: 'groups#show', html_options: {class: 'home'}) do = link_to group_path(@group), title: 'Home' do - = icon('dashboard fw') + = icon('group fw') %span Group - if can?(current_user, :read_group, @group) + = nav_link(path: 'groups#activity') do + = link_to activity_group_path(@group), title: 'Activity' do + = icon('dashboard fw') + %span + Activity - if current_user = nav_link(controller: [:group, :milestones]) do = link_to group_milestones_path(@group), title: 'Milestones' do diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index f3ded04419b..3b9d31a6fc5 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -17,7 +17,7 @@ = icon('gear fw') %span Account - = nav_link(path: ['profiles#applications', 'applications#edit', 'applications#show', 'applications#new', 'applications#create']) do + = nav_link(controller: 'oauth/applications') do = link_to applications_profile_path, title: 'Applications' do = icon('cloud fw') %span diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 319974e12c5..0ae83ee01eb 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -16,7 +16,7 @@ = nav_link(path: 'projects#show', html_options: {class: 'home'}) do = link_to project_path(@project), title: 'Project', class: 'shortcuts-project' do - = icon('home fw') + = icon('bookmark fw') %span Project = nav_link(path: 'projects#activity') do diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 970da78a5c9..dc3050f02e5 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -13,16 +13,22 @@ = icon('pencil-square-o fw') %span Project Settings + - if @project.allowed_to_share_with_group? + = nav_link(controller: :group_links) do + = link_to namespace_project_group_links_path(@project.namespace, @project), title: "Groups" do + = icon('share-square-o fw') + %span + Groups = nav_link(controller: :deploy_keys) do = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do = icon('key fw') %span Deploy Keys = nav_link(controller: :hooks) do - = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Web Hooks' do + = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do = icon('link fw') %span - Web Hooks + Webhooks = nav_link(controller: :services) do = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do = icon('cogs fw') diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 325c68c69dc..37b4d562966 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -42,12 +42,15 @@ - else #{link_to "View it on GitLab", @target_url}. %br - -# Don't link the host is the line below, one link in the email is easier to quickly click than two. + -# Don't link the host in the line below, one link in the email is easier to quickly click than two. You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. If you'd like to receive fewer emails, you can - - if @sent_notification && @sent_notification.unsubscribable? - = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) - from this thread or - adjust your notification settings. + - if @labels_url + adjust your #{link_to 'label subscriptions', @labels_url}. + - else + - if @sent_notification && @sent_notification.unsubscribable? + = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) + from this thread or + adjust your notification settings. = email_action @target_url diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb index 855d37429d9..daf20a226dd 100644 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ b/app/views/notify/_reassigned_issuable_email.text.erb @@ -1,6 +1,6 @@ Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, {only_path: false}]) %> +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml new file mode 100644 index 00000000000..80a0de255be --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -0,0 +1,3 @@ +%p + #{'Label'.pluralize(@label_names.size)} added: + %em= @label_names.to_sentence diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb new file mode 100644 index 00000000000..6a83d79fd61 --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -0,0 +1,3 @@ +<%= 'Label'.pluralize(@label_names.size) %> added: <%= @label_names.to_sentence %> + +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> diff --git a/app/views/notify/relabeled_issue_email.html.haml b/app/views/notify/relabeled_issue_email.html.haml new file mode 100644 index 00000000000..b17b16e1814 --- /dev/null +++ b/app/views/notify/relabeled_issue_email.html.haml @@ -0,0 +1 @@ += render 'relabeled_issuable_email', issuable: @issue diff --git a/app/views/notify/relabeled_issue_email.text.erb b/app/views/notify/relabeled_issue_email.text.erb new file mode 100644 index 00000000000..eeced97f601 --- /dev/null +++ b/app/views/notify/relabeled_issue_email.text.erb @@ -0,0 +1 @@ +<%= render 'relabeled_issuable_email', issuable: @issue %> diff --git a/app/views/notify/relabeled_merge_request_email.html.haml b/app/views/notify/relabeled_merge_request_email.html.haml new file mode 100644 index 00000000000..9eaa9afa5b1 --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.html.haml @@ -0,0 +1 @@ += render 'relabeled_issuable_email', issuable: @merge_request diff --git a/app/views/notify/relabeled_merge_request_email.text.erb b/app/views/notify/relabeled_merge_request_email.text.erb new file mode 100644 index 00000000000..87bc80ead32 --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= render 'relabeled_issuable_email', issuable: @merge_request %> diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 9fa96084f94..6efd119f260 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -5,114 +5,113 @@ .alert.alert-info Some options are unavailable for LDAP accounts -.account-page.prepend-top-default - .panel.panel-default.update-token - .panel-heading - Reset Private token - .panel-body - = form_for @user, url: reset_private_token_profile_path, method: :put do |f| - .data - %p - Your private token is used to access application resources without authentication. - %br - It can be used for atom feeds or the API. - %span.cred - Keep it secret! - - %p.cgray - - if current_user.private_token - = text_field_tag "token", current_user.private_token, class: "form-control" - - else - %span You don`t have one yet. Click generate to fix it. - - .form-actions - - if current_user.private_token - = f.submit 'Reset private token', data: { confirm: "Are you sure?" }, class: "btn btn-default" - - else - = f.submit 'Generate', class: "btn btn-default" - - .panel.panel-default - .panel-heading +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + Private Token + %p + Your private token is used to access application resources without authentication. + .col-lg-9 + = form_for @user, url: reset_private_token_profile_path, method: :put, html: {class: "private-token"} do |f| + %p.cgray + - if current_user.private_token + = label_tag "token", "Private token", class: "label-light" + = text_field_tag "token", current_user.private_token, class: "form-control" + - else + %span You don`t have one yet. Click generate to fix it. + %p.help-block + It can be used for atom feeds or the API. Keep it secret! + .prepend-top-default + - if current_user.private_token + = f.submit 'Reset private token', data: { confirm: "Are you sure?" }, class: "btn btn-default" + - else + = f.submit 'Generate', class: "btn btn-default" +%hr +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 Two-factor Authentication - .panel-body - - if current_user.two_factor_enabled? - .pull-right - = link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-close btn-sm', + %p + Increase your account's security by enabling two-factor authentication (2FA). + .col-lg-9 + %p + Status: #{current_user.two_factor_enabled? ? 'enabled' : 'disabled'} + - if !current_user.two_factor_enabled? + %p + Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code. + More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. + .append-bottom-10 + = link_to 'Enable two-factor authentication', new_profile_two_factor_auth_path, class: 'btn btn-success' + - else + = link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-danger', data: { confirm: 'Are you sure?' } - %p.text-success - %strong - Two-factor Authentication is enabled - %p - If you lose your recovery codes you can - %strong - = succeed ',' do - = link_to 'generate new ones', codes_profile_two_factor_auth_path, method: :post, data: { confirm: 'Are you sure?' } - invalidating all previous codes. - - - else - %p - Increase your account's security by enabling two-factor authentication (2FA). - %p - Each time you log in you’ll be required to provide your username and - password as usual, plus a randomly-generated code from your phone. - - .form-actions - = link_to 'Enable Two-factor Authentication', new_profile_two_factor_auth_path, class: 'btn btn-success' - - - if button_based_providers.any? - .panel.panel-default - .panel-heading +%hr +- if button_based_providers.any? + .row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + Social sign-in + %p + Activate signin with one of the following services + .col-lg-9 + %label.label-light Connected Accounts - .panel-body - .oauth-buttons.append-bottom-10 - %p Click on icon to activate signin with one of the following services - - button_based_providers.each do |provider| - .btn-group - = link_to provider_image_tag(provider), user_omniauth_authorize_path(provider), method: :post, class: "btn btn-lg #{'active' if auth_active?(provider)}", "data-no-turbolink" => "true" - - - if auth_active?(provider) - = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'btn btn-lg' do - = icon('close') - - - if current_user.can_change_username? - .panel.panel-warning.update-username - .panel-heading - Change Username - .panel-body - = form_for @user, url: update_username_profile_path, method: :put, remote: true do |f| - %p - Changing your username will change path to all personal projects! - %div - .input-group - .input-group-addon - = "#{root_url}u/" - = f.text_field :username, required: true, class: 'form-control' - - .loading-gif.hide - %p - = icon('spinner spin') - Saving new username - .form-actions - = f.submit 'Save username', class: "btn btn-warning" + %p Click on icon to activate signin with one of the following services + - button_based_providers.each do |provider| + .provider-btn-group + .provider-btn-image + = provider_image_tag(provider) + - if auth_active?(provider) + = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do + Disconnect + - else + = link_to user_omniauth_authorize_path(provider), method: :post, class: "provider-btn #{'not-active' if !auth_active?(provider)}", "data-no-turbolink" => "true" do + Connect + %hr +- if current_user.can_change_username? + .row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0.change-username-title + Change username + %p + Changing your username will change path to all personal projects! + .col-lg-9 + = form_for @user, url: update_username_profile_path, method: :put, remote: true, html: {class: "update-username"} do |f| + .form-group + = f.label :username, "Path", class: "label-light" + .input-group + .input-group-addon + = "#{root_url}u/" + = f.text_field :username, required: true, class: 'form-control' + .help-block + Current path: + = "#{root_url}u/#{current_user.username}" + .prepend-top-default + = f.button class: "btn btn-warning", type: "submit" do + = icon "spinner spin", class: "hidden loading-username" + Update username + %hr - - if signup_enabled? - .panel.panel-danger.remove-account - .panel-heading +- if signup_enabled? + .row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0.remove-account-title Remove account - .panel-body - - if @user.can_be_removed? - %p Deleting an account has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = current_user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored - .form-actions - = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" - - else - - if @user.solo_owned_groups.present? - %p - Your account is currently an owner in these groups: - %strong #{@user.solo_owned_groups.map(&:name).join(', ')} - %p - You must transfer ownership or delete these groups before you can delete your account. + .col-lg-9 + - if @user.can_be_removed? + %p + Deleting an account has the following effects: + %ul + %li All user content like authored issues, snippets, comments will be removed + - rp = current_user.personal_projects.count + - unless rp.zero? + %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" + - else + - if @user.solo_owned_groups.present? + %p + Your account is currently an owner in these groups: + %strong #{@user.solo_owned_groups.map(&:name).join(', ')} + %p + You must transfer ownership or delete these groups before you can delete your account. +.append-bottom-default diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml deleted file mode 100644 index 86f35823406..00000000000 --- a/app/views/profiles/applications.html.haml +++ /dev/null @@ -1,70 +0,0 @@ -- page_title "Applications" -- header_title page_title, applications_profile_path - -.alert.alert-help.prepend-top-default - - if user_oauth_applications? - Manage applications that can use GitLab as an OAuth provider, - and applications that you've authorized to use your account. - - else - Manage applications that you've authorized to use your account. - -- if user_oauth_applications? - .oauth-applications - %h3 - Your applications - .pull-right - = link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' - - if @applications.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th Clients - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link btn-sm' - %td= render 'doorkeeper/applications/delete_form', application: application - -.oauth-authorized-applications.prepend-top-20 - - if user_oauth_applications? - %h3 - Authorized applications - - - if @authorized_tokens.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Authorized At - %th Scope - %th - %tbody - - @authorized_apps.each do |app| - - token = app.authorized_tokens.order('created_at desc').first - %tr{:id => "application_#{app.id}"} - %td= app.name - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', application: app - - @authorized_anonymous_tokens.each do |token| - %tr - %td - Anonymous - %div.help-block - %em Authorization was granted by entering your username and password in the application. - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', token: token - - else - %p.light You don't have any authorized applications diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 3d1ba49491c..cd582ba7060 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,7 +1,4 @@ = form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f| - = f.hidden_field :avatar_crop_x - = f.hidden_field :avatar_crop_y - = f.hidden_field :avatar_crop_size -if @user.errors.any? %div.alert.alert-danger %ul @@ -97,19 +94,3 @@ .prepend-top-default.append-bottom-default = f.submit 'Update profile settings', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" - -.modal.modal-profile-crop - .modal-dialog - .modal-content - .modal-header - %button.close{type: 'button', data: {dismiss: 'modal'}} - %span - × - %h4.modal-title - Crop your new profile picture - .modal-body - %p - %img.modal-profile-crop-image - .modal-footer - %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} - Set new profile picture diff --git a/app/views/profiles/two_factor_auths/new.html.haml b/app/views/profiles/two_factor_auths/new.html.haml index b2830aa0834..5d342ef58e5 100644 --- a/app/views/profiles/two_factor_auths/new.html.haml +++ b/app/views/profiles/two_factor_auths/new.html.haml @@ -1,41 +1,41 @@ - page_title 'Two-factor Authentication', 'Account' -%h2.page-title Two-factor Authentication (2FA) -%p - Download the Google Authenticator application from App Store for iOS or Google - Play for Android and scan this code. - - More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. - -%hr - -= form_tag profile_two_factor_auth_path, method: :post, class: 'form-horizontal two-factor-new' do |f| - - if @error - .alert.alert-danger - = @error - .form-group - .col-lg-2.col-lg-offset-2 - = raw @qr_code - .col-lg-7.col-lg-offset-1.manual-instructions - %h3 Can't scan the code? - - %p - To add the entry manually, provide the following details to the - application on your phone. - - %dl - %dt Account - %dd= current_user.email - %dl - %dt Key - %dd= current_user.otp_secret.scan(/.{4}/).join(' ') - %dl - %dt Time based - %dd Yes - .form-group - = label_tag :pin_code, nil, class: "control-label" - .col-lg-10 - = text_field_tag :pin_code, nil, class: "form-control", required: true, autofocus: true - .form-actions - = submit_tag 'Submit', class: 'btn btn-success' - = link_to 'Configure it later', skip_profile_two_factor_auth_path, :method => :patch, class: 'btn btn-cancel' if two_factor_skippable? +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + Two-factor Authentication (2FA) + %p + Increase your account's security by enabling two-factor authentication (2FA). + .col-lg-9 + %p + Status: #{current_user.two_factor_enabled? ? 'enabled' : 'disabled'} + %p + Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code. + More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. + .row.append-bottom-10 + .col-md-3 + = raw @qr_code + .col-md-9 + .account-well + %p.prepend-top-0.append-bottom-0 + Can't scan the code? + %p.prepend-top-0.append-bottom-0 + To add the entry manually, provide the following details to the application on your phone. + %p.prepend-top-0.append-bottom-0 + Account: + = current_user.email + %p.prepend-top-0.append-bottom-0 + Key: + = current_user.otp_secret.scan(/.{4}/).join(' ') + %p.two-factor-new-manual-content + Time based: Yes + = form_tag profile_two_factor_auth_path, method: :post do |f| + - if @error + .alert.alert-danger + = @error + .form-group + = label_tag :pin_code, nil, class: "label-light" + = text_field_tag :pin_code, nil, class: "form-control", required: true + .prepend-top-default + = submit_tag 'Enable two-factor authentication', class: 'btn btn-success' + = link_to 'Configure it later', skip_profile_two_factor_auth_path, :method => :patch, class: 'btn btn-cancel' if two_factor_skippable? diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml index 14f1d3226bb..811d304ea75 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -55,7 +55,6 @@ %th Coverage %th - - @builds.each do |build| - = render 'projects/commit_statuses/commit_status', commit_status: build, commit_sha: true, stage: true, coverage: @project.build_coverage_enabled?, allow_retry: true + = render @builds, commit_sha: true, stage: true, allow_retry: true, coverage: @project.build_coverage_enabled? = paginate @builds, theme: 'gitlab' diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index 8eec78a557c..b02aee3db21 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -13,9 +13,10 @@ = link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request) #up-build-trace - - if @commit.matrix_for_ref?(@build.ref) + - builds = @build.commit.matrix_builds(@build) + - if builds.size > 1 %ul.nav-links.no-top.no-bottom - - @commit.latest_builds_for_ref(@build.ref).each do |build| + - builds.each do |build| %li{class: ('active' if build == @build) } = link_to namespace_project_build_path(@project.namespace, @project, build) do = ci_icon_for_status(build.status) @@ -44,7 +45,7 @@ .pull-right #{time_ago_with_tooltip(@build.finished_at) if @build.finished_at} - - if @build.show_warning? + - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning %p @@ -70,7 +71,7 @@ .autoscroll-container %button.btn.btn-success.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} enable autoscroll .clearfix - .scroll-controls + #js-build-scroll.scroll-controls = link_to '#up-build-trace', class: 'btn' do %i.fa.fa-angle-up = link_to '#down-build-trace', class: 'btn' do @@ -100,12 +101,12 @@ %h4.title Build artifacts .center .btn-group{ role: :group } - = link_to @build.artifacts_download_url, class: 'btn btn-sm btn-primary' do + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do = icon('download') Download - if @build.artifacts_metadata? - = link_to @build.artifacts_browse_url, class: 'btn btn-sm btn-primary' do + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do = icon('folder-open') Browse @@ -115,10 +116,10 @@ - if can?(current_user, :update_build, @project) .center .btn-group{ role: :group } - - if @build.cancel_url - = link_to "Cancel", @build.cancel_url, class: 'btn btn-sm btn-danger', method: :post - - elsif @build.retry_url - = link_to "Retry", @build.retry_url, class: 'btn btn-sm btn-primary', method: :post + - if @build.active? + = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-danger', method: :post + - elsif @build.retryable? + = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary', method: :post - if @build.erasable? = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), 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/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml new file mode 100644 index 00000000000..d22d1da8402 --- /dev/null +++ b/app/views/projects/ci/builds/_build.html.haml @@ -0,0 +1,76 @@ +%tr.build + %td.status + - if can?(current_user, :read_build, build) + = ci_status_with_icon(build.status, namespace_project_build_url(build.project.namespace, build.project, build)) + - else + = ci_status_with_icon(build.status) + + %td.build-link + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do + %strong ##{build.id} + - else + %strong ##{build.id} + + - if build.stuck? + %i.fa.fa-warning.text-warning + + - if defined?(commit_sha) && commit_sha + %td + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" + + %td + - if build.ref + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if build.try(:runner) + = runner_link(build.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = build.stage + + %td + = build.name + + .pull-right + - if build.tags.any? + - build.tags.each do |tag| + %span.label.label-primary + = tag + - if build.try(:trigger_request) + %span.label.label-info triggered + - if build.try(:allow_failure) + %span.label.label-danger allowed to fail + + %td.duration + - if build.duration + #{duration_in_words(build.finished_at, build.started_at)} + + %td.timestamp + - if build.finished_at + %span #{time_ago_with_tooltip(build.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if build.try(:coverage) + #{build.coverage}% + + %td + .pull-right + - if can?(current_user, :read_build, build) && build.artifacts? + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do + %i.fa.fa-download + - if can?(current_user, :update_build, build) + - if build.active? + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do + %i.fa.fa-repeat diff --git a/app/views/projects/commit/_builds.html.haml b/app/views/projects/commit/_builds.html.haml index befad27666c..003b7c18d0e 100644 --- a/app/views/projects/commit/_builds.html.haml +++ b/app/views/projects/commit/_builds.html.haml @@ -43,8 +43,8 @@ %th Coverage %th - @ci_commit.refs.each do |ref| - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.statuses.for_ref(ref).latest.ordered, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true } + - builds = @ci_commit.statuses.for_ref(ref).latest.ordered + = render builds, coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true - if @ci_commit.retried.any? .gray-content-block.second-block @@ -64,5 +64,4 @@ - if @ci_commit.project.build_coverage_enabled? %th Coverage %th - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.retried, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true } + = render @ci_commit.retried, coverage: @ci_commit.project.build_coverage_enabled?, stage: true diff --git a/app/views/projects/commit_statuses/_commit_status.html.haml b/app/views/projects/commit_statuses/_commit_status.html.haml deleted file mode 100644 index a3449d1ae05..00000000000 --- a/app/views/projects/commit_statuses/_commit_status.html.haml +++ /dev/null @@ -1,79 +0,0 @@ -%tr.commit_status - %td.status - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url, class: "ci-status ci-#{commit_status.status}" do - = ci_icon_for_status(commit_status.status) - = commit_status.status - - else - = ci_status_with_icon(commit_status.status) - - %td.commit_status-link - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url do - %strong ##{commit_status.id} - - else - %strong ##{commit_status.id} - - - if commit_status.show_warning? - %i.fa.fa-warning.text-warning{data: { toggle: "tooltip" }, title: "This build is stuck, open it to know more"} - - - if defined?(commit_sha) && commit_sha - %td - = link_to commit_status.short_sha, namespace_project_commit_path(commit_status.project.namespace, commit_status.project, commit_status.sha), class: "monospace" - - %td - - if commit_status.ref - = link_to commit_status.ref, namespace_project_commits_path(commit_status.project.namespace, commit_status.project, commit_status.ref) - - else - .light none - - - if defined?(runner) && runner - %td - - if commit_status.try(:runner) - = runner_link(commit_status.runner) - - else - .light none - - - if defined?(stage) && stage - %td - = commit_status.stage - - %td - = commit_status.name - - .pull-right - - if commit_status.tags.any? - - commit_status.tags.each do |tag| - %span.label.label-primary - = tag - - if commit_status.try(:trigger_request) - %span.label.label-info triggered - - if commit_status.try(:allow_failure) - %span.label.label-danger allowed to fail - - %td.duration - - if commit_status.duration - #{duration_in_words(commit_status.finished_at, commit_status.started_at)} - - %td.timestamp - - if commit_status.finished_at - %span #{time_ago_with_tooltip(commit_status.finished_at)} - - - if defined?(coverage) && coverage - %td.coverage - - if commit_status.try(:coverage) - #{commit_status.coverage}% - - %td - .pull-right - - if can?(current_user, :read_commit_status, commit_status) && commit_status.artifacts_download_url - = link_to commit_status.artifacts_download_url, title: 'Download artifacts' do - %i.fa.fa-download - - if can?(current_user, :update_commit_status, commit_status) - - if commit_status.active? - - if commit_status.cancel_url - = link_to commit_status.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && commit_status.retry_url - = link_to commit_status.retry_url, method: :post, title: 'Retry' do - %i.fa.fa-repeat diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml new file mode 100644 index 00000000000..c15386b4883 --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -0,0 +1,58 @@ +%tr.generic_commit_status + %td.status + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = ci_status_with_icon(generic_commit_status.status, generic_commit_status.target_url) + - else + = ci_status_with_icon(generic_commit_status.status) + + %td.generic_commit_status-link + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url do + %strong ##{generic_commit_status.id} + - else + %strong ##{generic_commit_status.id} + + - if defined?(commit_sha) && commit_sha + %td + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "monospace" + + %td + - if generic_commit_status.ref + = link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if generic_commit_status.try(:runner) + = runner_link(generic_commit_status.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = generic_commit_status.stage + + %td + = generic_commit_status.name + + .pull-right + - if generic_commit_status.tags.any? + - generic_commit_status.tags.each do |tag| + %span.label.label-primary + = tag + + %td.duration + - if generic_commit_status.duration + #{duration_in_words(generic_commit_status.finished_at, generic_commit_status.started_at)} + + %td.timestamp + - if generic_commit_status.finished_at + %span #{time_ago_with_tooltip(generic_commit_status.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if generic_commit_status.try(:coverage) + #{generic_commit_status.coverage}% + + %td diff --git a/app/views/projects/go_import.html.haml b/app/views/projects/go_import.html.haml deleted file mode 100644 index 87ac75a350f..00000000000 --- a/app/views/projects/go_import.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -!!! 5 -%html - %head - - web_url = [Gitlab.config.gitlab.url, @namespace, @id].join('/') - %meta{name: "go-import", content: "#{web_url.split('://')[1]} git #{web_url}.git"} diff --git a/app/views/projects/group_links/index.html.haml b/app/views/projects/group_links/index.html.haml new file mode 100644 index 00000000000..13f5fc141fa --- /dev/null +++ b/app/views/projects/group_links/index.html.haml @@ -0,0 +1,41 @@ +- page_title "Groups" +%h3.page_title Share project with other groups +%p.light + Projects can be stored in only one group at once. However you can share a project with other groups here. +%hr +- if @group_links.present? + .enabled-groups.panel.panel-default + .panel-heading + Already shared with + %ul.well-list + - @group_links.each do |group_link| + - group = group_link.group + %li + .pull-right + = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: 'btn btn-sm' do + %i.icon-remove + disable sharing + = link_to group do + %strong + %i.icon-folder-open + = group.name + %br + .light up to #{group_link.human_access} + + +.available-groups + %h4 + Can be shared with + %div + = form_tag namespace_project_group_links_path(@project.namespace, @project), method: :post, class: 'form-horizontal' do + .form-group + = label_tag :link_group_id, 'Group', class: 'control-label' + .col-sm-10 + = groups_select_tag(:link_group_id, skip_group: @project.group.try(:path)) + .form-group + = label_tag :link_group_access, 'Max access level', class: 'control-label' + .col-sm-10 + = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control" + .form-actions + = submit_tag "Share", class: "btn btn-create" + diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index a0511819c9f..67d016bd871 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -1,9 +1,9 @@ -- page_title "Web Hooks" +- page_title "Webhooks" %h3.page-title - Web hooks + Webhooks %p.light - #{link_to "Web hooks ", help_page_path("web_hooks", "web_hooks"), class: "vlink"} can be + #{link_to "Webhooks ", help_page_path("web_hooks", "web_hooks"), class: "vlink"} can be used for binding events when something is happening within the project. %hr.clearfix @@ -70,12 +70,12 @@ = f.check_box :enable_ssl_verification %strong Enable SSL verification .form-actions - = f.submit "Add Web Hook", class: "btn btn-create" + = f.submit "Add Webhook", class: "btn btn-create" -if @hooks.any? .panel.panel-default .panel-heading - Web hooks (#{@hooks.count}) + Webhooks (#{@hooks.count}) %ul.well-list - @hooks.each do |hook| %li diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index eb9c225df2f..b151393abab 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,7 @@ - content_for :note_actions do - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn btn-nr btn-grouped btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn btn-nr btn-grouped btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, status_only: true, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-grouped btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, status_only: true, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-grouped btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes = render 'projects/notes/notes_with_form' diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index 640a1962ffc..d9868ad1f0a 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -11,7 +11,7 @@ - elsif has_any_ci = icon('blank fw') %span.merge-request-id - \!#{merge_request.iid} + = merge_request.to_reference %span.merge-request-info %strong = link_to_gfm merge_request.title, merge_request_path(merge_request), class: "row_title" diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 617b0437807..0242276cd84 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -18,7 +18,7 @@ %span.hidden-sm.hidden-md.hidden-lg = icon('circle-o') - %a.btn.btn-default.pull-right.hidden-sm.hidden-md.hidden-lg.gutter-toggle{ href: "#" } + %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.js-sidebar-toggle{ href: "#" } = icon('angle-double-left') .issue-meta @@ -71,7 +71,7 @@ .merge-requests = render 'merge_requests' - .content-block + .content-block.content-block-small = render 'votes/votes_block', votable: @issue .row diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index f7ddd30c5a9..4927d239c1e 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -10,6 +10,16 @@ = link_to_label(label) do = pluralize label.open_issues_count, 'open issue' + - if current_user + .label-subscription{data: {url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}} + .subscription-status{data: {status: label_subscription_status(label)}} + %button.btn.btn-sm.btn-info.subscribe-button + %span= label_subscription_toggle_button_text(label) + - if can? current_user, :admin_label, @project = link_to 'Edit', edit_namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm' = link_to 'Delete', namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Remove this label? Are you sure?"} + +- if current_user + :javascript + new Subscription('##{dom_id(label)} .label-subscription'); diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 1c7de94acfd..393998f15b9 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -1,8 +1,8 @@ - content_for :note_actions do - if can?(current_user, :update_merge_request, @merge_request) - if @merge_request.open? - = link_to 'Close', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-close close-mr-link js-note-target-close", title: "Close merge request" + = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} - if @merge_request.closed? - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request" + = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} #notes= render "projects/notes/notes_with_form" diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index b262892ac65..ee5b9fd95a8 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -68,7 +68,7 @@ .tab-content #notes.notes.tab-pane.voting_notes - .content-block.oneline-block + .content-block.content-block-small.oneline-block = render 'votes/votes_block', votable: @merge_request .row diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml index d24c12251f3..a75c0d96c57 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -4,7 +4,7 @@ = @merge_request.state_human_name %span.hidden-sm.hidden-md.hidden-lg = icon(@merge_request.state_icon_name) - %a.btn.btn-default.pull-right.hidden-sm.hidden-md.hidden-lg.gutter-toggle{ href: "#" } + %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.js-sidebar-toggle{ href: "#" } = icon('angle-double-left') .issue-meta %strong.identifier 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/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index 09740d8ea12..f675f092da1 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -13,6 +13,7 @@ .error-alert .note-form-actions.clearfix - = f.submit 'Add Comment', class: "btn btn-nr btn-create comment-btn btn-grouped js-comment-button" + = f.submit 'Comment', class: "btn btn-nr btn-create comment-btn btn-grouped js-comment-button" = yield(:note_actions) - %a.btn.btn-nr.btn-cancel.js-close-discussion-note-form Cancel + %a.btn.btn-cancel.js-note-discard{role: "button", data: {cancel_text: "Cancel"}} + Discard draft diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml new file mode 100644 index 00000000000..62888e41935 --- /dev/null +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -0,0 +1,21 @@ +- @project_group_links.each do |group_links| + - shared_group = group_links.group + - shared_group_users_count = group_links.group.group_members.count + .panel.panel-default + .panel-heading + Shared with + %strong #{shared_group.name} + group, members with + %strong #{group_links.human_access} + role (#{shared_group_users_count}) + - if current_user.can?(:admin_group, shared_group) + .panel-head-actions + = link_to group_group_members_path(shared_group), class: 'btn btn-sm' do + %i.fa.fa-pencil-square-o + Edit group members + %ul.content-list + - shared_group.group_members.order('access_level DESC').limit(20).each do |member| + = render 'groups/group_members/group_member', member: member, show_controls: false, show_roles: false + - if shared_group_users_count > 20 + %li + and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)} diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 0f8848a5cbe..ebcfc907ebb 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -18,3 +18,6 @@ - if @group = render "group_members", members: @group_members + + - if @project_group_links.any? && @project.allowed_to_share_with_group? + = render "shared_group_members" diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index ec478a5963d..4ef544136a8 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -6,14 +6,21 @@ - else Any %b.caret - %ul.dropdown-menu - %li - = link_to search_filter_path(group_id: nil) do - Any - - current_user.authorized_groups.sort_by(&:name).each do |group| - %li - = link_to search_filter_path(group_id: group.id, project_id: nil) do - = group.name + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Filter results by group + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-content + %ul + %li + = link_to search_filter_path(group_id: nil), class: ("is-active" if !params[:group_id].present?) do + Any + %li.divider + - current_user.authorized_groups.sort_by(&:name).each do |group| + %li + = link_to search_filter_path(group_id: group.id, project_id: nil), class: ("is-active" if params[:group_id] == group.id.to_s) do + = group.name .dropdown.inline.prepend-left-10.project-filter %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} @@ -23,11 +30,18 @@ - else Any %b.caret - %ul.dropdown-menu - %li - = link_to search_filter_path(project_id: nil) do - Any - - current_user.authorized_projects.sort_by(&:name_with_namespace).each do |project| - %li - = link_to search_filter_path(project_id: project.id, group_id: nil) do - = project.name_with_namespace + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Filter results by project + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-content + %ul + %li + = link_to search_filter_path(project_id: nil), class: ("is-active" if !params[:project_id].present?) do + Any + %li.divider + - current_user.authorized_projects.sort_by(&:name_with_namespace).each do |project| + %li + = link_to search_filter_path(project_id: project.id, group_id: nil), class: ("is-active" if params[:project_id] == project.id.to_s) do + = project.name_with_namespace diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index e55159d996b..42a3c2c3f02 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 js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", + 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), null_user: true, 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/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 36f06377886..23b1ed1e51b 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -6,7 +6,7 @@ of = issuables_count(issuable) %span.pull-right - %a.gutter-toggle{href: '#'} + %a.gutter-toggle.js-sidebar-toggle{href: '#'} = sidebar_gutter_toggle_icon .issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'} - if prev_issuable = prev_issuable_for(issuable) @@ -98,7 +98,7 @@ %hr - if current_user - subscribed = issuable.subscribed?(current_user) - .block.light + .block.light.subscription{data: {url: toggle_subscription_path(issuable)}} .sidebar-collapsed-icon = icon('rss') .title.hide-collapsed @@ -124,5 +124,5 @@ = clipboard_button(clipboard_text: project_ref) :javascript - new Subscription("#{toggle_subscription_path(issuable)}"); + new Subscription('.subscription'); new IssuableContext(); 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/app/views/votes/_votes_block.html.haml b/app/views/votes/_votes_block.html.haml index 176fd29cb57..20d2d5f317b 100644 --- a/app/views/votes/_votes_block.html.haml +++ b/app/views/votes/_votes_block.html.haml @@ -1,14 +1,17 @@ .awards.votes-block - awards_sort(votable.notes.awards.grouped_awards).each do |emoji, notes| - .award{class: (note_active_class(notes, current_user)), title: emoji_author_list(notes, current_user)} + %button.btn.award-control.js-emoji-btn.has_tooltip{class: (note_active_class(notes, current_user)), title: emoji_author_list(notes, current_user), data: {placement: "top"}} = emoji_icon(emoji) - .counter + %span.award-control-text.js-counter = notes.count - if current_user - .awards-controls - %a.add-award{"href" => "#"} - = icon('smile-o') + %div.award-menu-holder.js-award-holder + %a.btn.award-control.js-add-award{"href" => "#"} + = icon('smile-o', {class: "award-control-icon"}) + = icon('spinner spin', {class: "award-control-icon award-control-icon-loading"}) + %span.award-control-text + Add - if current_user :javascript @@ -23,17 +26,3 @@ noteable_id, aliases ); - - $(".awards").on("click", ".emoji-menu-content li", function(e) { - var emoji = $(this).find(".emoji-icon").data("emoji"); - awards_handler.addAward(emoji); - }); - - $(".awards").on("click", ".award", function(e) { - var emoji = $(this).find(".icon").data("emoji"); - awards_handler.addAward(emoji); - }); - - $(".award").tooltip(); - - $(".emoji-menu-content").niceScroll({cursorwidth: "7px", autohidemode: false}); |