Commit 5174d6c1 authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge remote-tracking branch 'upstream/master' into feature/runner-lock-on-project

* upstream/master: (337 commits)
  Update CHANGELOG for !4659
  Center the header logo for all Devise emails
  Add previews for all customized Devise emails
  Customize the Devise `unlock_instructions` email
  Customize the Devise `reset_password_instructions` email
  Customize the Devise `password_change` emails
  Use gitlab-git 10.2.0
  Use Git cached counters on project show page
  Fix indentation scss-lint errors
  Added title attribute to enties in tree view Closes #18353
  Banzai::Filter::ExternalLinkFilter use XPath
  Reduce queries in IssueReferenceFilter
  Use gitlab_git 10.1.4
  Fixed ordering in Project.find_with_namespace
  Fix images in emails
  Banzai::Filter::UploadLinkFilter use XPath
  Turn Group#owners into a has_many association
  Make project_id nullable
  CHANGELOG [ci skip]
  CHANGELOG [ci skip]
  ...
parents 8a0aeab8 faee4763
This diff is collapsed.
......@@ -52,7 +52,7 @@ gem "browser", '~> 2.0.3'
# Extracting information from a git repository
# Provide access to Gitlab::Git library
gem "gitlab_git", '~> 10.0'
gem "gitlab_git", '~> 10.2'
# LDAP Auth
# GitLab fork with several improvements to original library. For full list of changes
......@@ -210,6 +210,9 @@ gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding
gem 'charlock_holmes', '~> 0.7.3'
# Parse duration
gem 'chronic_duration', '~> 0.10.6'
gem "sass-rails", '~> 5.0.0'
gem "coffee-rails", '~> 4.1.0'
gem "uglifier", '~> 2.7.2'
......@@ -224,7 +227,6 @@ gem 'gon', '~> 6.0.1'
gem 'jquery-atwho-rails', '~> 1.3.2'
gem 'jquery-rails', '~> 4.1.0'
gem 'jquery-ui-rails', '~> 5.0.0'
gem 'raphael-rails', '~> 2.1.2'
gem 'request_store', '~> 1.3.0'
gem 'select2-rails', '~> 3.5.9'
gem 'virtus', '~> 1.0.1'
......
......@@ -50,7 +50,7 @@ GEM
after_commit_queue (1.3.0)
activerecord (>= 3.0)
akismet (2.0.0)
allocations (1.0.4)
allocations (1.0.5)
arel (6.0.3)
asana (0.4.0)
faraday (~> 0.9)
......@@ -124,6 +124,8 @@ GEM
mime-types (>= 1.16)
cause (0.1)
charlock_holmes (0.7.3)
chronic_duration (0.10.6)
numerizer (~> 0.1.1)
chunky_png (1.3.5)
cliver (0.3.2)
coderay (1.1.0)
......@@ -275,7 +277,7 @@ GEM
posix-spawn (~> 0.3)
gitlab_emoji (0.3.1)
gemojione (~> 2.2, >= 2.2.1)
gitlab_git (10.1.0)
gitlab_git (10.2.0)
activesupport (~> 4.0)
charlock_holmes (~> 0.7.3)
github-linguist (~> 4.7.0)
......@@ -398,7 +400,7 @@ GEM
mime-types (>= 1.16, < 4)
mail_room (0.7.0)
method_source (0.8.2)
mime-types (2.99.1)
mime-types (2.99.2)
mimemagic (0.3.0)
mini_portile2 (2.1.0)
minitest (5.7.0)
......@@ -414,6 +416,7 @@ GEM
nokogiri (1.6.8)
mini_portile2 (~> 2.1.0)
pkg-config (~> 1.1.7)
numerizer (0.1.1)
oauth (0.4.7)
oauth2 (1.0.0)
faraday (>= 0.8, < 0.10)
......@@ -553,7 +556,6 @@ GEM
rainbow (2.1.0)
raindrops (0.15.0)
rake (10.5.0)
raphael-rails (2.1.2)
rb-fsevent (0.9.6)
rb-inotify (0.9.5)
ffi (>= 0.5.0)
......@@ -839,6 +841,7 @@ DEPENDENCIES
capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0)
charlock_holmes (~> 0.7.3)
chronic_duration (~> 0.10.6)
coffee-rails (~> 4.1.0)
connection_pool (~> 2.0)
coveralls (~> 0.8.2)
......@@ -871,7 +874,7 @@ DEPENDENCIES
github-markup (~> 1.3.1)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab_emoji (~> 0.3.0)
gitlab_git (~> 10.0)
gitlab_git (~> 10.2)
gitlab_meta (= 7.0)
gitlab_omniauth-ldap (~> 1.2.1)
gollum-lib (~> 4.1.0)
......@@ -934,7 +937,6 @@ DEPENDENCIES
rails (= 4.2.6)
rails-deprecated_sanitizer (~> 1.0.3)
rainbow (~> 2.1.0)
raphael-rails (~> 2.1.2)
rblineprof
rdoc (~> 3.6)
recaptcha (~> 3.0)
......
......@@ -42,10 +42,10 @@ class @LabelManager
$from = @prioritizedLabels
if $from.find('li').length is 1
$from.find('.empty-message').show()
$from.find('.empty-message').removeClass('hidden')
if not $target.find('li').length
$target.find('.empty-message').hide()
$target.find('.empty-message').addClass('hidden')
$label.detach().appendTo($target)
......@@ -54,6 +54,9 @@ class @LabelManager
if action is 'remove'
xhr = $.ajax url: url, type: 'DELETE'
# Restore empty message
$from.find('.empty-message').removeClass('hidden') unless $from.find('li').length
else
xhr = @savePrioritySort($label, action)
......
......@@ -32,10 +32,6 @@
#= require bootstrap/tooltip
#= require bootstrap/popover
#= require select2
#= require raphael
#= require g.raphael
#= require g.bar
#= require branch-graph
#= require ace/ace
#= require ace/ext-searchbox
#= require underscore
......@@ -125,9 +121,10 @@ window.onload = ->
setTimeout shiftWindow, 100
$ ->
gl.utils.preventDisabledButtons()
bootstrapBreakpoint = bp.getBreakpointSize()
$(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF")
$(".nav-sidebar").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF")
# Click a .js-select-on-focus field, select the contents
$(".js-select-on-focus").on "focusin", ->
......@@ -257,3 +254,31 @@ $ ->
gl.awardsHandler = new AwardsHandler()
checkInitialSidebarSize()
new Aside()
# Sidenav pinning
if $(window).width() < 1440 and $.cookie('pin_nav') is 'true'
$.cookie('pin_nav', 'false')
$('.page-with-sidebar')
.toggleClass('page-sidebar-collapsed page-sidebar-expanded')
.removeClass('page-sidebar-pinned')
$('.navbar-fixed-top').removeClass('header-pinned-nav')
$(document)
.off 'click', '.js-nav-pin'
.on 'click', '.js-nav-pin', (e) ->
e.preventDefault()
$(this).toggleClass 'is-active'
if $.cookie('pin_nav') is 'true'
$.cookie 'pin_nav', 'false'
$('.page-with-sidebar')
.removeClass('page-sidebar-pinned')
.toggleClass('page-sidebar-collapsed page-sidebar-expanded')
$('.navbar-fixed-top')
.removeClass('header-pinned-nav')
.toggleClass('header-collapsed header-expanded')
else
$.cookie 'pin_nav', 'true'
$('.page-with-sidebar').addClass('page-sidebar-pinned')
$('.navbar-fixed-top').addClass('header-pinned-nav')
......@@ -40,7 +40,7 @@ class @AwardsHandler
$menu = $ '.emoji-menu'
if $addBtn.hasClass 'js-note-emoji'
$addBtn.parents('.note').find('.js-awards-block').addClass 'current'
$addBtn.closest('.note').find('.js-awards-block').addClass 'current'
else
$addBtn.closest('.js-awards-block').addClass 'current'
......
class @BlobGitignoreSelector
constructor: (opts) ->
{
@dropdown
@editor
@$wrapper = @dropdown.closest('.gitignore-selector')
@$filenameInput = $('#file_name')
@data = @dropdown.data('filenames')
} = opts
#= require blob/template_selector
@dropdown.glDropdown(
data: @data,
filterable: true,
selectable: true,
search:
fields: ['name']
clicked: @onClick
text: (gitignore) ->
gitignore.name
)
@toggleGitignoreSelector()
@bindEvents()
bindEvents: ->
@$filenameInput
.on 'keyup blur', (e) =>
@toggleGitignoreSelector()
toggleGitignoreSelector: ->
filename = @$filenameInput.val() or $('.editor-file-name').text().trim()
@$wrapper.toggleClass 'hidden', filename isnt '.gitignore'
onClick: (item, el, e) =>
e.preventDefault()
@requestIgnoreFile(item.name)
requestIgnoreFile: (name) ->
Api.gitignoreText name, @requestIgnoreFileSuccess.bind(@)
requestIgnoreFileSuccess: (gitignore) ->
@editor.setValue(gitignore.content, 1)
@editor.focus()
class @BlobGitignoreSelectors
constructor: (opts) ->
{
@$dropdowns = $('.js-gitignore-selector')
@editor
} = opts
@$dropdowns.each (i, dropdown) =>
$dropdown = $(dropdown)
new BlobGitignoreSelector(
dropdown: $dropdown,
editor: @editor
)
class @BlobGitignoreSelector extends TemplateSelector
requestFile: (query) ->
Api.gitignoreText query.name, @requestFileSuccess.bind(@)
class @BlobGitignoreSelectors
constructor: (opts) ->
{
@$dropdowns = $('.js-gitignore-selector')
@editor
} = opts
@$dropdowns.each (i, dropdown) =>
$dropdown = $(dropdown)
new BlobGitignoreSelector(
pattern: /(.gitignore)/,
data: $dropdown.data('data'),
wrapper: $dropdown.closest('.js-gitignore-selector-wrap'),
dropdown: $dropdown,
editor: @editor
)
class @BlobLicenseSelector
licenseRegex: /^(.+\/)?(licen[sc]e|copying)($|\.)/i
#= require blob/template_selector
constructor: (editor) ->
@$licenseSelector = $('.js-license-selector')
$fileNameInput = $('#file_name')
class @BlobLicenseSelector extends TemplateSelector
requestFile: (query) ->
data =
project: @dropdown.data('project')
fullname: @dropdown.data('fullname')
initialFileNameValue = if $fileNameInput.length
$fileNameInput.val()
else if $('.editor-file-name').length
$('.editor-file-name').text().trim()
@toggleLicenseSelector(initialFileNameValue)
if $fileNameInput
$fileNameInput.on 'keyup blur', (e) =>
@toggleLicenseSelector($(e.target).val())
$('select.license-select').on 'change', (e) ->
data =
project: $(this).data('project')
fullname: $(this).data('fullname')
Api.licenseText $(this).val(), data, (license) ->
editor.setValue(license.content, -1)
toggleLicenseSelector: (fileName) =>
if @licenseRegex.test(fileName)
@$licenseSelector.show()
else
@$licenseSelector.hide()
Api.licenseText query.id, data, @requestFileSuccess.bind(@)
class @BlobLicenseSelectors
constructor: (opts) ->
{
@$dropdowns = $('.js-license-selector')
@editor
} = opts
@$dropdowns.each (i, dropdown) =>
$dropdown = $(dropdown)
new BlobLicenseSelector(
pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i,
data: $dropdown.data('data'),
wrapper: $dropdown.closest('.js-license-selector-wrap'),
dropdown: $dropdown,
editor: @editor
)
......@@ -12,8 +12,9 @@ class @EditBlob
$("#file-content").val(@editor.getValue())
@initModePanesAndLinks()
new BlobLicenseSelector(@editor)
new BlobGitignoreSelectors(editor: @editor)
new BlobLicenseSelectors { @editor }
new BlobGitignoreSelectors { @editor }
initModePanesAndLinks: ->
@$editModePanes = $(".js-edit-mode-pane")
......
class @TemplateSelector
constructor: (opts = {}) ->
{
@dropdown,
@data,
@pattern,
@wrapper,
@editor,
@fileEndpoint,
@$input = $('#file_name')
} = opts
@buildDropdown()
@bindEvents()
@onFilenameUpdate()
buildDropdown: ->
@dropdown.glDropdown(
data: @data,
filterable: true,
selectable: true,
search:
fields: ['name']
clicked: @onClick
text: (item) ->
item.name
)
bindEvents: ->
@$input.on('keyup blur', (e) =>
@onFilenameUpdate()
)
onFilenameUpdate: ->
return unless @$input.length
filenameMatches = @pattern.test(@$input.val().trim())
if not filenameMatches
@wrapper.addClass('hidden')
return
@wrapper.removeClass('hidden')
onClick: (item, el, e) =>
e.preventDefault()
@requestFile(item)
requestFile: (item) ->
# To be implemented on the extending class
# e.g.
# Api.gitignoreText item.name, @requestFileSuccess.bind(@)
requestFileSuccess: (file) ->
@editor.setValue(file.content, 1)
@editor.focus()
......@@ -17,6 +17,8 @@ class @CiBuild
.off 'resize.build'
.on 'resize.build', @hideSidebar
@updateArtifactRemoveDate()
if $('#build-trace').length
@getInitialBuildTrace()
@initScrollButtonAffix()
......@@ -103,3 +105,10 @@ class @CiBuild
$('.js-build-sidebar')
.removeClass 'right-sidebar-collapsed'
.addClass 'right-sidebar-expanded'
updateArtifactRemoveDate: ->
$date = $('.js-artifacts-remove')
if $date.length
date = $date.text()
$date.text $.timefor(new Date(date), ' ')
......@@ -29,6 +29,7 @@ class Dispatcher
new Todos()
when 'projects:milestones:new', 'projects:milestones:edit'
new ZenMode()
new DueDateSelect()
new GLForm($('.milestone-form'))
when 'groups:milestones:new'
new ZenMode()
......@@ -53,9 +54,13 @@ class Dispatcher
new Diff()
shortcut_handler = new ShortcutsIssuable(true)
new ZenMode()
new MergedButtons()
when 'projects:merge_requests:commits', 'projects:merge_requests:builds'
new MergedButtons()
when "projects:merge_requests:diffs"
new Diff()
new ZenMode()
new MergedButtons()
when 'projects:merge_requests:index'
shortcut_handler = new ShortcutsNavigation()
Issuable.init()
......@@ -68,9 +73,7 @@ class Dispatcher
new Diff()
new ZenMode()
shortcut_handler = new ShortcutsNavigation()
when 'projects:commits:show'
shortcut_handler = new ShortcutsNavigation()
when 'projects:activity'
when 'projects:commits:show', 'projects:activity'
shortcut_handler = new ShortcutsNavigation()
when 'projects:show'
shortcut_handler = new ShortcutsNavigation()
......@@ -96,6 +99,7 @@ class Dispatcher
when 'projects:blob:show', 'projects:blame:show'
new LineHighlighter()
shortcut_handler = new ShortcutsNavigation()
new ShortcutsBlob true
when 'projects:labels:new', 'projects:labels:edit'
new Labels()
when 'projects:labels:index'
......@@ -129,15 +133,11 @@ class Dispatcher
new Project()
new ProjectAvatar()
switch path[1]
when 'compare'
shortcut_handler = new ShortcutsNavigation()
when 'edit'
shortcut_handler = new ShortcutsNavigation()
new ProjectNew()
when 'new'
when 'new', 'show'
new ProjectNew()
when 'show'
new ProjectShow()
when 'wikis'
new Wikis()
shortcut_handler = new ShortcutsNavigation()
......@@ -146,9 +146,9 @@ class Dispatcher
when 'snippets'
shortcut_handler = new ShortcutsNavigation()
new ZenMode() if path[2] == 'show'
when 'labels', 'graphs'
shortcut_handler = new ShortcutsNavigation()
when 'project_members', 'deploy_keys', 'hooks', 'services', 'protected_branches'
when 'labels', 'graphs', 'compare', 'pipelines', 'forks', \
'milestones', 'project_members', 'deploy_keys', 'builds', \
'hooks', 'services', 'protected_branches'
shortcut_handler = new ShortcutsNavigation()
# If we haven't installed a custom shortcut handler, install the default one
......
class @DueDateSelect
constructor: ->
# Milestone edit/new form
$datePicker = $('.datepicker')
if $datePicker.length
$dueDate = $('#milestone_due_date')
$datePicker.datepicker
dateFormat: 'yy-mm-dd'
onSelect: (dateText, inst) ->
$dueDate.val(dateText)
.datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val()))
$('.js-clear-due-date').on 'click', (e) ->
e.preventDefault()
$.datepicker._clearDate($datePicker)
# Issuable sidebar
$loading = $('.js-issuable-update .due_date')
.find('.block-loading')
.hide()
......@@ -32,7 +48,7 @@ class @DueDateSelect
date = new Date value.replace(new RegExp('-', 'g'), ',')
mediumDate = $.datepicker.formatDate 'M d, yy', date
else
mediumDate = 'None'
mediumDate = 'No due date'
data = {}
data[abilityName] = {}
......@@ -50,7 +66,8 @@ class @DueDateSelect
$selectbox.hide()
$value.css('display', '')
$valueContent.html(mediumDate)
cssClass = if Date.parse(mediumDate) then 'bold' else 'no-value'
$valueContent.html("<span class='#{cssClass}'>#{mediumDate}</span>")
$sidebarValue.html(mediumDate)
if value isnt ''
......
......@@ -15,6 +15,9 @@ GitLab.GfmAutoComplete =
Members:
template: '<li>${username} <small>${title}</small></li>'
Labels:
template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>'
# Issues and MergeRequests
Issues:
template: '<li><small>${id}</small> ${title}</li>'
......@@ -176,6 +179,25 @@ GitLab.GfmAutoComplete =
title: sanitize(m.title)
search: "#{m.iid} #{m.title}"
@input.atwho
at: '~'
alias: 'labels'
searchKey: 'search'
displayTpl: @Labels.template
insertTpl: '${atwho-at}${title}'
callbacks:
beforeSave: (merges) ->
sanitizeLabelTitle = (title)->
if /\w+\s+\w+/g.test(title)
"\"#{sanitize(title)}\""
else
sanitize(title)
$.map merges, (m) ->
title: sanitizeLabelTitle(m.title)
color: m.color
search: "#{m.title}"
destroyAtWho: ->
@input.atwho('destroy')
......@@ -195,6 +217,8 @@ GitLab.GfmAutoComplete =
@input.atwho 'load', 'mergerequests', data.mergerequests
# load emojis
@input.atwho 'load', ':', data.emojis
# load labels
@input.atwho 'load', '~', data.labels
# This trigger at.js again
# otherwise we would be stuck with loading until the user types
......
......@@ -56,13 +56,6 @@ issuable_created = false
Issuable.filterResults $('.filter-form')
$('.js-label-select').trigger('update.label')
toggleLabelFilters: ->
$filteredLabels = $('.filtered-labels')
if $filteredLabels.find('.label-row').length > 0
$filteredLabels.removeClass('hidden')
else
$filteredLabels.addClass('hidden')
filterResults: (form) =>
formData = form.serialize()
......@@ -71,58 +64,16 @@ issuable_created = false
issuesUrl = formAction
issuesUrl += ("#{if formAction.indexOf('?') < 0 then '?' else '&'}")
issuesUrl += formData
$.ajax
type: 'GET'
url: formAction
data: formData
complete: ->
$('.issues-holder, .merge-requests-holder').css('opacity', '1.0')
success: (data) ->
$('.issues-holder, .merge-requests-holder').html(data.html)
# Change url so if user reload a page - search results are saved
history.replaceState {page: issuesUrl}, document.title, issuesUrl
Issuable.reload()
Issuable.updateStateFilters()
$filteredLabels = $('.filtered-labels')
if typeof Issuable.labelRow is 'function'
$filteredLabels.html(Issuable.labelRow(data))
Issuable.toggleLabelFilters()
dataType: "json"
reload: ->
if Issuable.created
Issuable.initChecks()
$('#filter_issue_search').val($('#issue_search').val())
Turbolinks.visit(issuesUrl);
initChecks: ->
$('.check_all_issues').on 'click', ->
$('.check_all_issues').off('click').on('click', ->
$('.selected_issue').prop('checked', @checked)
Issuable.checkChanged()
)
$('.selected_issue').on 'change', Issuable.checkChanged
updateStateFilters: ->
stateFilters = $('.issues-state-filters, .dropdown-menu-sort')
newParams = {}
paramKeys = ['author_id', 'milestone_title', 'assignee_id', 'issue_search', 'issue_search']
for paramKey in paramKeys
newParams[paramKey] = gl.utils.getParameterValues(paramKey)[0] or ''
if stateFilters.length
stateFilters.find('a').each ->
initialUrl = gl.utils.removeParamQueryString($(this).attr('href'), 'label_name[]')
labelNameValues = gl.utils.getParameterValues('label_name[]')
if labelNameValues
labelNameQueryString = ("label_name[]=#{value}" for value in labelNameValues).join('&')
newUrl = "#{gl.utils.mergeUrlParams(newParams, initialUrl)}&#{labelNameQueryString}"
else
newUrl = gl.utils.mergeUrlParams(newParams, initialUrl)
$(this).attr 'href', newUrl
$('.selected_issue').off('change').on('change', Issuable.checkChanged)
checkChanged: ->
checked_issues = $('.selected_issue:checked')
......
......@@ -102,6 +102,10 @@ class @IssuableForm
return {
results: data
}
data: (query) ->
{
search: query
}
formatResult: (project) ->
project.name_with_namespace
formatSelection: (project) ->
......
......@@ -9,6 +9,9 @@ class @IssuableBulkActions
@bindEvents()
# Fixes bulk-assign not working when navigating through pages
Issuable.initChecks();
getElement: (selector) ->
@container.find selector
......
......@@ -39,7 +39,7 @@ class @LabelsSelect
</a>
<% }); %>'
)
labelNoneHTMLTemplate = _.template('<div class="light">None</div>')
labelNoneHTMLTemplate = '<span class="no-value">None</span>'
if newLabelField.length
......@@ -145,7 +145,7 @@ class @LabelsSelect
template = labelHTMLTemplate(data)
labelCount = data.labels.length
else
template = labelNoneHTMLTemplate()
template = labelNoneHTMLTemplate
$value
.removeAttr('style')
.html(template)
......
class @LayoutNav
$ ->
$('.fade-left').addClass('end-scroll')
$('.scrolling-tabs').on 'scroll', (event) ->
$this = $(this)
$el = $(event.target)
currentPosition = $this.scrollLeft()
size = bp.getBreakpointSize()
controlBtnWidth = $('.controls').width()
maxPosition = $this.get(0).scrollWidth - $this.parent().width()
maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length
$el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0)
$el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition)
hideEndFade = ($scrollingTabs) ->
$scrollingTabs.each ->
$this = $(@)
$this
.find('.fade-right')
.toggleClass('end-scroll', $this.width() is $this.prop('scrollWidth'))
$ ->
$('.fade-left').addClass('end-scroll')
hideEndFade($('.scrolling-tabs'))
$(window)
.off 'resize.nav'
.on 'resize.nav', ->
hideEndFade($('.scrolling-tabs'))
$('.scrolling-tabs').on 'scroll', (event) ->
$this = $(this)
currentPosition = $this.scrollLeft()
maxPosition = $this.prop('scrollWidth') - $this.outerWidth()
$this.find('.fade-left').toggleClass('end-scroll', currentPosition is 0)
$this.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition)
((w) ->
w.gl or= {}
w.gl.utils or= {}
w.gl.utils.isInGroupsPage = ->
return $('body').data('page').split(':')[0] is 'groups'
w.gl.utils.isInProjectPage = ->
return $('body').data('page').split(':')[0] is 'projects'
w.gl.utils.getProjectSlug = ->
return if @isInProjectPage() then $('body').data 'project' else null
w.gl.utils.getGroupSlug = ->
return if @isInGroupsPage() then $('body').data 'group' else null
gl.utils.updateTooltipTitle = ($tooltipEl, newTitle) ->
$tooltipEl
.tooltip 'destroy'
.attr 'title', newTitle
.tooltip 'fixTitle'
gl.utils.preventDisabledButtons = ->
$('.btn').click (e) ->
if $(this).hasClass 'disabled'
e.preventDefault()
e.stopImmediatePropagation()
return false
jQuery.timefor = (time, suffix, expiredLabel) ->
return '' unless time
......
......@@ -42,9 +42,3 @@ work = ->
$(document).on('page:fetch', start)
$(document).on('page:change', stop)
$ ->
# Make logo clickable as part of a workaround for Safari visited
# link behaviour (See !2690).
$('#logo').on 'click', ->
Turbolinks.visit('/')
......@@ -9,7 +9,7 @@ class @MergeRequest
# Options:
# action - String, current controller action
#
constructor: (@opts) ->
constructor: (@opts = {}) ->
this.$el = $('.merge-request')
this.$('.show-all-commits').on 'click', =>
......
......@@ -88,7 +88,7 @@ class @MergeRequestTabs
scrollToElement: (container) ->
if window.location.hash
navBarHeight = $('.navbar-gitlab').outerHeight()
navBarHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()
$el = $("#{container} #{window.location.hash}:not(.match)")
$.scrollTo("#{container} #{window.location.hash}:not(.match)", offset: -navBarHeight) if $el.length
......
class @MergedButtons
constructor: ->
@$removeBranchWidget = $('.remove_source_branch_widget')
@$removeBranchProgress = $('.remove_source_branch_in_progress')
@$removeBranchFailed = $('.remove_source_branch_widget.failed')
@cleanEventListeners()
@initEventListeners()
cleanEventListeners: ->
$(document).off 'click', '.remove_source_branch'
$(document).off 'ajax:success', '.remove_source_branch'
$(document).off 'ajax:error', '.remove_source_branch'
initEventListeners: ->
$(document).on 'click', '.remove_source_branch', @removeSourceBranch
$(document).on 'ajax:success', '.remove_source_branch', @removeBranchSuccess
$(document).on 'ajax:error', '.remove_source_branch', @removeBranchError
removeSourceBranch: =>
@$removeBranchWidget.hide()
@$removeBranchProgress.show()
removeBranchSuccess: ->
location.reload()
removeBranchError: ->
@$removeBranchWidget.hide()
@$removeBranchProgress.hide()
@$removeBranchFailed.show()
......@@ -24,14 +24,10 @@ class @MilestoneSelect
if issueUpdateURL
milestoneLinkTemplate = _.template(
'<a href="/<%= namespace %>/<%= path %>/milestones/<%= iid %>">
<span class="has-tooltip" data-container="body" title="<%= remaining %>">
<%= _.escape(title) %>
</span>
</a>'
'<a href="/<%= namespace %>/<%= path %>/milestones/<%= iid %>" class="bold has-tooltip" data-container="body" title="<%= remaining %>"><%= _.escape(title) %></a>'
)
milestoneLinkNoneTemplate = '<div class="light">None</div>'
milestoneLinkNoneTemplate = '<span class="no-value">None</span>'
collapsedSidebarLabelTemplate = _.template(
'<span class="has-tooltip" data-container="body" title="<%= remaining %>" data-placement="left">
......@@ -116,7 +112,7 @@ class @MilestoneSelect
.val()
data = {}
data[abilityName] = {}
data[abilityName].milestone_id = selected
data[abilityName].milestone_id = if selected? then selected else null
$loading
.fadeIn()
$dropdown.trigger('loading.gl.dropdown')
......
# This is a manifest file that'll be compiled into including all the files listed below.
# Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
# be included in the compiled file accessible from http://example.com/assets/application.js
# It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
# the compiled file.
#
#= require raphael
#= require g.raphael
#= require g.bar
#= require_tree .
$ ->
network_graph = new Network({
url: $(".network-graph").attr('data-url'),
commit_url: $(".network-graph").attr('data-commit-url'),
ref: $(".network-graph").attr('data-ref'),
commit_id: $(".network-graph").attr('data-commit-id')
})
new ShortcutsNetwork(network_graph.branch_graph)
......@@ -115,12 +115,14 @@ class @Notes
, @pollingInterval
refresh: =>
return if @refreshing is true
@refreshing = true
if not document.hidden and document.URL.indexOf(@noteable_url) is 0
@getContent()
getContent: ->
return if @refreshing
@refreshing = true
$.ajax
url: @notes_url
data: "last_fetched_at=" + @last_fetched_at
......
......@@ -43,6 +43,55 @@ class @Sidebar
$('.right-sidebar')
.hasClass('right-sidebar-collapsed'), { path: '/' })
$(document)
.off 'click', '.js-issuable-todo'
.on 'click', '.js-issuable-todo', @toggleTodo
toggleTodo: (e) =>
$this = $(e.currentTarget)
$todoLoading = $('.js-issuable-todo-loading')
$btnText = $('.js-issuable-todo-text', $this)
ajaxType = if $this.attr('data-id') then 'PATCH' else 'POST'
ajaxUrlExtra = if $this.attr('data-id') then "/#{$this.attr('data-id')}" else ''
$.ajax(
url: "#{$this.data('url')}#{ajaxUrlExtra}"
type: ajaxType
dataType: 'json'
data:
issuable_id: $this.data('issuable')
issuable_type: $this.data('issuable-type')
beforeSend: =>
@beforeTodoSend($this, $todoLoading)
).done (data) =>
@todoUpdateDone(data, $this, $btnText, $todoLoading)
beforeTodoSend: ($btn, $todoLoading) ->
$btn.disable()
$todoLoading.removeClass 'hidden'
todoUpdateDone: (data, $btn, $btnText, $todoLoading) ->
$todoPendingCount = $('.todos-pending-count')
$todoPendingCount.text data.count
$btn.enable()
$todoLoading.addClass 'hidden'
if data.count is 0
$todoPendingCount.addClass 'hidden'
else
$todoPendingCount.removeClass 'hidden'
if data.todo?
$btn
.attr 'aria-label', $btn.data('mark-text')
.attr 'data-id', data.todo.id
$btnText.text $btn.data('mark-text')
else
$btn
.attr 'aria-label', $btn.data('todo-text')
.removeAttr 'data-id'
$btnText.text $btn.data('todo-text')
sidebarDropdownLoading: (e) ->
$sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon')
......@@ -117,5 +166,3 @@ class @Sidebar
getBlock: (name) ->
@sidebar.find(".block.#{name}")
......@@ -67,8 +67,12 @@ class @SearchAutocomplete
getData: (term, callback) ->
_this = @
# Do not trigger request if input is empty
return if @searchInput.val() is ''
unless term
if contents = @getCategoryContents()
@searchInput.data('glDropdown').filter.options.callback contents
@enableAutocomplete()
return
# Prevent multiple ajax calls
return if @loadingSuggestions
......@@ -122,6 +126,37 @@ class @SearchAutocomplete
).always ->
_this.loadingSuggestions = false
getCategoryContents: ->
userId = gon.current_user_id
{ utils, projectOptions, groupOptions, dashboardOptions } = gl
if utils.isInGroupsPage() and groupOptions
options = groupOptions[utils.getGroupSlug()]
else if utils.isInProjectPage() and projectOptions
options = projectOptions[utils.getProjectSlug()]
else if dashboardOptions
options = dashboardOptions
{ issuesPath, mrPath, name } = options
items = [
{ header: "#{name}" }
{ text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" }
{ text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" }
'separator'
{ text: 'Merge requests assigned to me', url: "#{mrPath}/?assignee_id=#{userId}" }
{ text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" }
]
items.splice 0, 1 unless name
return items
serializeState: ->
{
# Search Criteria
......@@ -209,6 +244,12 @@ class @SearchAutocomplete
@isFocused = true
@wrap.addClass('search-active')
@getData() if @getValue() is ''
getValue: -> return @searchInput.val()
onClearInputClick: (e) =>
e.preventDefault()
@searchInput.val('').focus()
......@@ -229,6 +270,10 @@ class @SearchAutocomplete
@locationBadgeEl.text(badgeText).show()
@wrap.addClass('has-location-badge')
hasLocationBadge: -> return @wrap.is '.has-location-badge'
restoreOriginalState: ->
inputs = Object.keys @originalState
......@@ -257,13 +302,14 @@ class @SearchAutocomplete
@getElement("##{input}").val('')
removeLocationBadge: ->
@locationBadgeEl.hide()
# Reset state
@locationBadgeEl.hide()
@resetSearchState()
@wrap.removeClass('has-location-badge')
@disableAutocomplete()
disableAutocomplete: ->
@searchInput.addClass('disabled')
......
class @Shortcuts
constructor: ->
constructor: (skipResetBindings) ->
@enabledHelp = []
Mousetrap.reset()
Mousetrap.reset() if not skipResetBindings
Mousetrap.bind('?', @onToggleHelp)
Mousetrap.bind('s', Shortcuts.focusSearch)
Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], @toggleMarkdownPreview)
......
#= require shortcuts
class @ShortcutsBlob extends Shortcuts
constructor: (skipResetBindings) ->
super skipResetBindings
Mousetrap.bind('y', ShortcutsBlob.copyToClipboard)
@copyToClipboard: ->
clipboardButton = $('.btn-clipboard')
clipboardButton.click() if clipboardButton
......@@ -3,13 +3,33 @@ expanded = 'page-sidebar-expanded'
toggleSidebar = ->
$('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}")
$('header').toggleClass("header-collapsed header-expanded")
$('.navbar-fixed-top').toggleClass("header-collapsed header-expanded")
if $.cookie('pin_nav') is 'true'
$('.navbar-fixed-top').toggleClass('header-pinned-nav')
$('.page-with-sidebar').toggleClass('page-sidebar-pinned')
setTimeout ( ->
niceScrollBars = $('.nicescroll').niceScroll();
niceScrollBars = $('.nav-sidebar').niceScroll();
niceScrollBars.updateScrollBar();
), 300
$(document)
.off 'click', 'body'
.on 'click', 'body', (e) ->
unless $.cookie('pin_nav') is 'true'
$target = $(e.target)
$nav = $target.closest('.sidebar-wrapper')
pageExpanded = $('.page-with-sidebar').hasClass('page-sidebar-expanded')
$toggle = $target.closest('.toggle-nav-collapse, .side-nav-toggle')
if $nav.length is 0 and pageExpanded and $toggle.length is 0
$('.page-with-sidebar')
.toggleClass('page-sidebar-collapsed page-sidebar-expanded')
$('.navbar-fixed-top')
.toggleClass('header-collapsed header-expanded')
$(document).on("click", '.toggle-nav-collapse, .side-nav-toggle', (e) ->
e.preventDefault()
......
......@@ -9,9 +9,11 @@ class @Star
$this.parent().find('.star-count').text data.star_count
if isStarred
$starSpan.removeClass('starred').text 'Star'
gl.utils.updateTooltipTitle $this, 'Star project'
$starIcon.removeClass('fa-star').addClass 'fa-star-o'
else
$starSpan.addClass('starred').text 'Unstar'
gl.utils.updateTooltipTitle $this, 'Unstar project'
$starIcon.removeClass('fa-star-o').addClass 'fa-star'
return
......
......@@ -31,7 +31,7 @@ class @UsersSelect
assignTo = (selected) ->
data = {}
data[abilityName] = {}
data[abilityName].assignee_id = selected
data[abilityName].assignee_id = if selected? then selected else null
$loading
.fadeIn()
$dropdown.trigger('loading.gl.dropdown')
......@@ -72,7 +72,7 @@ class @UsersSelect
assigneeTemplate = _.template(
'<% if (username) { %>
<a class="author_link " href="/u/<%= username %>">
<a class="author_link bold" href="/u/<%= username %>">
<% if( avatar ) { %>
<img width="32" class="avatar avatar-inline s32" alt="" src="<%= avatar %>">
<% } %>
......@@ -82,7 +82,7 @@ class @UsersSelect
</span>
</a>
<% } else { %>
<span class="assign-yourself">
<span class="no-value assign-yourself">
No assignee -
<a href="#" class="js-assign-yourself">
assign yourself
......
......@@ -91,6 +91,10 @@
background-color: $white-light;
border-top: none;
}
&.top-block .container-fluid {
background-color: inherit;
}
}
.cover-block {
......
......@@ -8,8 +8,8 @@
*/
@mixin gitlab-theme($color-light, $color, $color-darker, $color-dark) {
.page-with-sidebar {
.collapse-nav a {
.toggle-nav-collapse,
.pin-nav-btn {
color: $color-light;
background: $color;
......
......@@ -2,8 +2,19 @@
* Application Header
*
*/
@mixin tanuki-logo-colors($path-color) {
fill: $path-color;
transition: all 0.8s;
&:hover,
&.highlight {
fill: lighten($path-color, 25%);
transition: all 0.1s;
}
}
header {
transition-duration: .3s;
transition: padding $sidebar-transition-duration;
&.navbar-empty {
height: $header-height;
......@@ -79,14 +90,9 @@ header {
&.header-collapsed {
padding: 0 16px;
.side-nav-toggle {
display: block;
}
}
.side-nav-toggle {
display: none;
position: absolute;
left: -10px;
margin: 6px 0;
......@@ -108,9 +114,7 @@ header {
.header-content {
position: relative;
height: $header-height;
padding-right: 40px;
padding-left: 30px;
transition-duration: .3s;
@media (min-width: $screen-sm-min) {
padding-right: 0;
......@@ -198,25 +202,24 @@ header {
}
}
.header-collapsed {
margin-left: 0;
#tanuki-logo {
.header-content {
@media (min-width: $screen-sm-max) {
padding-left: 30px;
transition-duration: .3s;
}
#tanuki-left-ear,
#tanuki-right-ear,
#tanuki-nose {
@include tanuki-logo-colors($tanuki-red);
}
}
.tanuki-shape {
transition: all 0.8s;
#tanuki-left-eye,
#tanuki-right-eye {
@include tanuki-logo-colors($tanuki-orange);
}
&:hover, &.highlight {
fill: rgb(255, 255, 255);
transition: all 0.1s;
#tanuki-left-cheek,
#tanuki-right-cheek {
@include tanuki-logo-colors($tanuki-yellow);
}
}
@media (max-width: $screen-xs-max) {
......
......@@ -159,7 +159,7 @@ ul.content-list {
background-color: $gray-light;
border: dotted 1px $gray-dark;
margin: 1px 0;
min-height: 30px;
min-height: 52px;
}
}
}
......
......@@ -74,6 +74,7 @@
.container-fluid {
background-color: $background-color;
margin-bottom: 0;
}
li {
......@@ -241,6 +242,12 @@
}
}
}
&.adjust {
.nav-text, .nav-controls {
width: auto;
}
}
}
.layout-nav {
......@@ -250,7 +257,7 @@
z-index: 11;
background: $background-color;
border-bottom: 1px solid $border-color;
transition-duration: .3s;
transition: padding $sidebar-transition-duration;
text-align: center;
.container-fluid {
......@@ -280,11 +287,10 @@
}
.dropdown {
margin-left: 7px;
@media (max-width: $screen-xs-min) {
margin-left: 0;
}
position: absolute;
top: 7px;
right: 15px;
z-index: 2;
li.active {
font-weight: bold;
......@@ -347,6 +353,12 @@
.badge {
color: $gl-icon-color;
}
&:hover {
a, i {
color: $black;
}
}
}
}
......
.page-with-sidebar {
padding-top: $header-height;
transition-duration: .3s;
transition: padding $sidebar-transition-duration;
.sidebar-wrapper {
position: fixed;
top: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
left: 0;
height: 100%;
transition-duration: .3s;
overflow: hidden;
transition: width $sidebar-transition-duration;
}
}
.sidebar-wrapper {
z-index: 1000;
background: $background-color;
.nicescroll-rails-hr {
// TODO: Figure out why nicescroll doesn't hide horizontal bar
display: none!important;
}
}
.content-wrapper {
width: 100%;
transition: padding $sidebar-transition-duration;
.container-fluid {
background: #fff;
......@@ -34,70 +39,63 @@
}
}
.sidebar-wrapper {
.sidebar-user {
padding: 15px 22px;
position: fixed;
bottom: 0;
width: $sidebar_width;
overflow: hidden;
transition-duration: .3s;
.sidebar-user {
padding: 15px;
position: absolute;
left: 0;
bottom: 0;
width: $sidebar_width;
overflow: hidden;
font-size: 16px;
line-height: 36px;
transition: width $sidebar-transition-duration, padding $sidebar-transition-duration;
.username {
margin-left: 10px;
width: $sidebar_width - 2 * 10px;
font-size: 16px;
line-height: 34px;
}
@media (min-width: $sidebar-breakpoint) {
bottom: 50px;
}
}
.nav-sidebar {
position: absolute;
top: 50px;
bottom: 65px;
width: $sidebar_width;
overflow-y: auto;
overflow-x: hidden;
.tanuki-shape {
transition: all 0.8s;
&:hover, &.highlight {
fill: rgb(255, 255, 255);
transition: all 0.1s;
@media (min-width: $sidebar-breakpoint) {
bottom: 115px;
}
}
.nav-sidebar {
margin-top: 22 + $header-height;
margin-bottom: 116px;
transition-duration: .3s;
list-style: none;
overflow: hidden;
&.navbar-collapse {
padding: 0 !important;
}
li {
width: $sidebar_width;
&.separate-item {
padding-top: 10px;
margin-top: 10px;
}
.icon-container {
width: 34px;
display: inline-block;
text-align: center;
}
a {
width: $sidebar_width;
padding: 7px 15px 7px 23px;
padding: 7px 15px 7px 12px;
font-size: $gl-font-size;
line-height: 24px;
display: block;
text-decoration: none;
font-weight: normal;
outline: none;
white-space: nowrap;
&:hover {
text-decoration: none;
}
&:active, &:focus {
&:hover,
&:active,
&:focus {
text-decoration: none;
}
......@@ -109,10 +107,6 @@
svg {
margin-right: 13px;
}
&.back-link i {
transition-duration: .3s;
}
}
}
......@@ -123,37 +117,50 @@
}
}
.sidebar-subnav {
margin-left: 0;
padding-left: 0;
li {
list-style: none;
}
}
.collapse-nav a {
.toggle-nav-collapse {
width: $sidebar_width;
position: fixed;
position: absolute;
top: 0;
left: 0;
min-height: 50px;
padding: 5px 0;
font-size: 18px;
background: transparent;
height: 50px;
text-align: center;
line-height: 40px;
line-height: 30px;
}
.nav-header-btn {
padding: 10px 5px;
color: inherit;
transition-duration: .3s;
outline: none;
&:hover {
&:hover,
&:focus {
color: $white-light;
text-decoration: none;
}
}
.sidebar-wrapper {
&.hidden-nav {
width: 0;
.pin-nav-btn {
display: none;
position: absolute;
left: 0;
bottom: 0;
height: 50px;
width: $sidebar_width;
line-height: 30px;
@media (min-width: $sidebar-breakpoint) {
display: block;
}
.fa {
transition: transform .15s;
}
&.is-active {
.fa {
transform: rotate(90deg);
}
}
}
......@@ -162,62 +169,34 @@
.sidebar-wrapper {
width: 0;
.nav-sidebar {
width: 0;
li {
width: auto;
a {
span {
display: none;
}
}
}
}
.collapse-nav a {
width: 0;
i {
display: none;
}
}
.sidebar-user {
width: 0;
padding-left: 0;
padding-right: 0;
.username {
display: none;
}
}
}
}
.page-sidebar-expanded {
@media (max-width: $screen-sm-max) {
padding-left: 0;
}
.sidebar-wrapper {
width: $sidebar_width;
}
}
.nav-sidebar {
width: $sidebar_width;
.page-sidebar-pinned {
.content-wrapper,
.layout-nav {
@media (min-width: $sidebar-breakpoint) {
padding-left: $sidebar_width;
}
}
}
.nav-sidebar li a {
width: $sidebar_width;
header.header-pinned-nav {
@media (min-width: $sidebar-breakpoint) {
padding-left: ($sidebar_width + $gl-padding);
&.back-link {
i {
opacity: 0;
}
}
.side-nav-toggle {
display: none;
}
.header-content {
padding-left: 0;
}
}
}
......
......@@ -6,6 +6,8 @@ $sidebar_width: 220px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
$gutter_inner_width: 258px;
$sidebar-transition-duration: .15s;
$sidebar-breakpoint: 1440px;
/*
* UI elements
......@@ -154,6 +156,11 @@ $warning-message-border: #f0e2bb;
/* header */
$light-grey-header: #faf9f9;
/* tanuki logo colors */
$tanuki-red: #e24329;
$tanuki-orange: #fc6d26;
$tanuki-yellow: #fca326;
/*
* State colors:
*/
......
......@@ -38,6 +38,10 @@ table {
margin: 0 auto;
text-align: left;
width: 600px;
& > td {
text-align: center;
}
}
&#body {
......
......@@ -7,84 +7,111 @@
margin-right: 9px;
}
.lists-separator {
margin: 10px 0;
border-color: #ddd;
.commit-header {
padding: 5px 10px;
background-color: $background-color;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
font-size: 14px;
&:first-child {
border-top-width: 0;
}
}
.commits-row {
ul {
margin: 0;
.commit-row-title {
line-height: 1;
margin-bottom: 7px;
li.commit {
padding: 8px 0;
}
.notes_count {
float: right;
margin-right: 10px;
}
.str-truncated {
max-width: 70%;
}
.commits-row-date {
font-size: 15px;
line-height: 20px;
margin-bottom: 5px;
.commit-row-message {
color: $gl-dark-link-color;
}
.text-expander {
display: inline-block;
background: $gray-light;
color: $gl-placeholder-color;
padding: 0 5px;
cursor: pointer;
border: 1px solid $border-gray-dark;
border-radius: $border-radius-default;
margin-left: 5px;
&:hover {
background-color: darken($gray-light, 10%);
text-decoration: none;
}
}
}
li.commit {
list-style: none;
.commit-actions {
@media (min-width: $screen-sm-min) {
float: right;
margin-left: $gl-padding;
margin-top: 2px;
font-size: 0;
}
.commit-row-title {
font-size: $list-font-size;
line-height: 20px;
margin-bottom: 2px;
.btn-transparent {
padding-left: 0;
padding-right: 0;
}
.btn-clipboard {
margin-top: -1px;
.btn {
&:not(:first-child) {
margin-left: $gl-padding;
}
}
}
.notes_count {
float: right;
margin-right: 10px;
}
.commit-short-id {
font-family: $monospace_font;
font-weight: 600;
}
.commit_short_id {
min-width: 65px;
color: $gl-dark-link-color;
font-family: $monospace_font;
}
.commit {
padding: 10px 0;
.str-truncated {
max-width: 70%;
}
@media (min-width: $screen-sm-min) {
padding-left: 46px;
}
.commit-row-message {
color: $gl-dark-link-color;
&:not(:last-child) {
border-bottom: 1px solid #eee;
}
&:hover {
text-decoration: underline;
}
}
a,
button {
color: $gl-dark-link-color;
vertical-align: baseline;
}
.text-expander {
background: #eee;
color: #555;
padding: 0 5px;
cursor: pointer;
margin-left: 4px;
&:hover {
background-color: #ddd;
}
}
.avatar {
margin-left: -46px;
}
.item-title {
display: inline-block;
max-width: 70%;
@media (min-width: $screen-sm-min) {
max-width: 70%;
}
}
.commit-row-description {
font-size: 14px;
border-left: 1px solid #eee;
padding: 10px 15px;
margin: 5px 0 10px 5px;
margin: 10px 0;
background: #f9f9f9;
display: none;
......@@ -93,6 +120,7 @@ li.commit {
background: inherit;
padding: 0;
margin: 0;
white-space: pre-wrap;
}
a {
......@@ -102,7 +130,7 @@ li.commit {
.commit-row-info {
color: $gl-gray;
line-height: 24px;
line-height: 1;
a {
color: $gl-gray;
......@@ -111,10 +139,6 @@ li.commit {
.avatar {
margin-right: 8px;
}
.committed_ago {
display: inline-block;
}
}
&.inline-commit {
......
......@@ -66,8 +66,7 @@
font-family: $regular_font;
}
.gitignore-selector {
.gitignore-selector, .license-selector {
.dropdown {
line-height: 21px;
}
......
.environments {
.commit-title {
margin: 0;
}
}
......@@ -39,3 +39,20 @@
}
}
}
.groups-cover-block {
.container-fluid {
position: relative;
}
.access-request-button {
@include btn-gray;
position: absolute;
right: 16px;
bottom: 32px;
padding: 3px 10px;
text-transform: none;
background-color: $background-color;
}
}
......@@ -34,6 +34,10 @@
color: inherit;
}
.issuable-header-text {
margin-top: 7px;
}
.block {
@include clearfix;
padding: $gl-padding 0;
......@@ -60,10 +64,6 @@
margin-top: 0;
}
.issuable-count {
margin-top: 7px;
}
.gutter-toggle {
margin-left: 20px;
padding-left: 10px;
......@@ -145,7 +145,6 @@
.assign-yourself {
margin-top: 10px;
font-weight: normal;
display: block;
}
}
......@@ -158,6 +157,10 @@
font-weight: normal;
}
.no-value {
color: $gl-placeholder-color;
}
.sidebar-collapsed-icon {
display: none;
}
......@@ -248,11 +251,16 @@
padding-bottom: 0;
margin-bottom: 10px;
}
.issuable-header-btn {
display: none;
}
}
.issuable-pager {
.issuable-header-btn {
background: $gray-normal;
border: 1px solid $border-gray-normal;
&:hover {
background: $gray-dark;
border: 1px solid $border-gray-dark;
......@@ -263,7 +271,7 @@
}
}
a:not(.issuable-pager) {
a {
&:hover {
color: $md-link-color;
text-decoration: none;
......@@ -322,7 +330,7 @@
margin-left: 5px;
a {
color: #8c8c8c;
color: $gl-placeholder-color;
}
}
......
......@@ -115,6 +115,13 @@
}
}
.draggable-handler {
display: inline-block;
opacity: 0;
transition: opacity .3s;
color: $gray-darkest;
}
.prioritized-labels {
margin-bottom: 30px;
......@@ -122,6 +129,13 @@
display: none;
color: $gray-light;
}
li:hover {
.draggable-handler {
display: inline-block;
opacity: 1;
}
}
}
.other-labels {
......
......@@ -313,3 +313,13 @@
}
}
}
.merged-buttons {
.btn {
float: left;
&:not(:last-child) {
margin-right: 10px;
}
}
}
......@@ -139,6 +139,12 @@ ul.notes {
@media (min-width: $screen-sm-min) {
padding-right: 0;
}
@media (max-width: $screen-xs-min) {
.inline {
display: block;
}
}
}
.note-emoji-button {
......@@ -258,7 +264,11 @@ ul.notes {
position: absolute;
right: 0;
top: 0;
.note-action-button {
margin-left: 10px;
}
@media (min-width: $screen-sm-min) {
position: relative;
}
......
......@@ -5,10 +5,12 @@
font-weight: normal;
}
}
.no-ssh-key-message, .project-limit-message {
background-color: #f28d35;
margin-bottom: 0;
}
.new_project,
.edit-project {
fieldset.features {
......@@ -18,13 +20,6 @@
}
}
.project-name-holder {
.help-inline {
vertical-align: top;
padding: 7px;
}
}
.project-home-panel {
background: $white-light;
text-align: left;
......@@ -33,7 +28,7 @@
.container-fluid {
position: relative;
@media (min-width: $screen-md-max) {
@media (min-width: $screen-lg-min) {
.row {
display: flex;
-ms-flex-align: center;
......@@ -229,13 +224,20 @@
right: 16px;
bottom: 0;
.btn {
padding: 3px 10px;
background-color: $background-color;
@media (max-width: $screen-md-max) {
top: 0;
}
@media (max-width: 1304px) {
top: 0;
.access-request-button {
position: absolute;
right: 0;
bottom: 61px;
@media (max-width: $screen-md-max) {
position: relative;
bottom: 0;
margin-right: 10px;
}
}
}
......@@ -286,10 +288,6 @@
color: #555;
}
.project_member_row form {
margin: 0;
}
.transfer-project .select2-container {
min-width: 200px;
}
......@@ -373,6 +371,7 @@ a.deploy-project-label {
.project-import .btn {
float: left;
margin-bottom: 10px;
margin-right: 10px;
}
......
......@@ -101,7 +101,7 @@
margin: 0;
.commit {
padding: 0;
padding: 0 0 0 55px;
.commit-row-title {
.commit-row-message {
......@@ -129,4 +129,6 @@
.tree-controls {
float: right;
margin-top: 11px;
position: relative;
z-index: 2;
}
......@@ -35,6 +35,7 @@ class AutocompleteController < ApplicationController
project = Project.find_by_id(params[:project_id])
projects = current_user.authorized_projects
projects = projects.search(params[:search]) if params[:search].present?
projects = projects.select do |project|
current_user.can?(:admin_issue, project)
end
......
module MembershipActions
extend ActiveSupport::Concern
include MembersHelper
def request_access
membershipable.request_access(current_user)
redirect_to polymorphic_path(membershipable),
notice: 'Your request for access has been queued for review.'
end
def approve_access_request
@member = membershipable.members.request.find(params[:id])
return render_403 unless can?(current_user, action_member_permission(:update, @member), @member)
@member.accept_request
redirect_to polymorphic_url([membershipable, :members])
end
def leave
@member = membershipable.members.find_by(user_id: current_user)
return render_403 unless @member
source_type = @member.real_source_type.humanize(capitalize: false)
if can?(current_user, action_member_permission(:destroy, @member), @member)
notice =
if @member.request?
"Your access request to the #{source_type} has been withdrawn."
else
"You left the \"#{@member.source.human_name}\" #{source_type}."
end
@member.destroy
redirect_to [:dashboard, @member.real_source_type.tableize], notice: notice
else
if cannot_leave?
alert = "You can not leave the \"#{@member.source.human_name}\" #{source_type}."
alert << " Transfer or delete the #{source_type}."
redirect_to polymorphic_url(membershipable), alert: alert
else
render_403
end
end
end
protected
def membershipable
raise NotImplementedError
end
def cannot_leave?
raise NotImplementedError
end
end
class Groups::GroupMembersController < Groups::ApplicationController
include MembershipActions
# Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave]
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access]
def index
@project = @group.projects.find(params[:project_id]) if params[:project_id]
@members = @group.group_members
@members = @members.non_invite unless can?(current_user, :admin_group, @group)
@members = @members.non_pending unless can?(current_user, :admin_group, @group)
if params[:search].present?
users = @group.users.search(params[:search]).to_a
......@@ -58,25 +60,16 @@ class Groups::GroupMembersController < Groups::ApplicationController
end
end
def leave
@group_member = @group.group_members.find_by(user_id: current_user)
if can?(current_user, :destroy_group_member, @group_member)
@group_member.destroy
redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.")
else
if @group.last_owner?(current_user)
redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.")
else
return render_403
end
end
end
protected
def member_params
params.require(:group_member).permit(:access_level, :user_id)
end
# MembershipActions concern
alias_method :membershipable, :group
def cannot_leave?
@group.last_owner?(current_user)
end
end
class Projects::ArtifactsController < Projects::ApplicationController
layout 'project'
before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep]
before_action :validate_artifacts!
def download
unless artifacts_file.file_storage?
return redirect_to artifacts_file.url
end
unless artifacts_file.exists?
return render_404
end
send_file artifacts_file.path, disposition: 'attachment'
end
def browse
return render_404 unless build.artifacts?
directory = params[:path] ? "#{params[:path]}/" : ''
@entry = build.artifacts_metadata_entry(directory)
......@@ -34,8 +30,17 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
end
def keep
build.keep_artifacts!
redirect_to namespace_project_build_path(project.namespace, project, build)
end
private
def validate_artifacts!
render_404 unless build.artifacts?
end
def build
@build ||= project.builds.find_by!(id: params[:build_id])
end
......
......@@ -51,7 +51,7 @@ class Projects::BuildsController < Projects::ApplicationController
return render_404
end
build = Ci::Build.retry(@build)
build = Ci::Build.retry(@build, current_user)
redirect_to build_path(build)
end
......
......@@ -46,7 +46,7 @@ class Projects::CommitController < Projects::ApplicationController
def retry_builds
ci_builds.latest.failed.each do |build|
if build.retryable?
Ci::Build.retry(build)
Ci::Build.retry(build, current_user)
end
end
......
class Projects::EnvironmentsController < Projects::ApplicationController
layout 'project'
before_action :authorize_read_environment!
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_update_environment!, only: [:destroy]
before_action :environment, only: [:show, :destroy]
def index
@environments = project.environments
end
def show
@deployments = environment.deployments.order(id: :desc).page(params[:page])
end
def new
@environment = project.environments.new
end
def create
@environment = project.environments.create(create_params)
if @environment.persisted?
redirect_to namespace_project_environment_path(project.namespace, project, @environment)
else
render 'new'
end
end
def destroy
if @environment.destroy
flash[:notice] = 'Environment was successfully removed.'
else
flash[:alert] = 'Failed to remove environment.'
end
redirect_to namespace_project_environments_path(project.namespace, project)
end
private
def create_params
params.require(:environment).permit(:name)
end
def environment
@environment ||= project.environments.find(params[:id])
end
end
......@@ -204,10 +204,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@merge_request.update(merge_error: nil)
if params[:merge_when_build_succeeds].present? && @merge_request.pipeline && @merge_request.pipeline.active?
MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params)
.execute(@merge_request)
@status = :merge_when_build_succeeds
if params[:merge_when_build_succeeds].present?
if @merge_request.pipeline && @merge_request.pipeline.active?
MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params)
.execute(@merge_request)
@status = :merge_when_build_succeeds
elsif @merge_request.pipeline.success?
# This can be triggered when a user clicks the auto merge button while
# the tests finish at about the same time
MergeWorker.perform_async(@merge_request.id, current_user.id, params)
@status = :success
else
@status = :failed
end
else
MergeWorker.perform_async(@merge_request.id, current_user.id, params)
@status = :success
......
......@@ -32,7 +32,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def retry
pipeline.retry_failed
pipeline.retry_failed(current_user)
redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
end
......
class Projects::ProjectMembersController < Projects::ApplicationController
include MembershipActions
# Authorize
before_action :authorize_admin_project_member!, except: [:leave, :index]
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index
@project_members = @project.project_members
@project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project)
@project_members = @project_members.non_pending unless can?(current_user, :admin_project, @project)
if params[:search].present?
users = @project.users.search(params[:search]).to_a
......@@ -14,9 +16,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@project_members = @project_members.order('access_level DESC')
@group = @project.group
if @group
@group_members = @group.group_members
@group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group)
@group_members = @group_members.non_pending unless can?(current_user, :admin_group, @group)
if params[:search].present?
users = @group.users.search(params[:search]).to_a
......@@ -73,26 +76,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
end
def leave
@project_member = @project.project_members.find_by(user_id: current_user)
if can?(current_user, :destroy_project_member, @project_member)
@project_member.destroy
respond_to do |format|
format.html { redirect_to dashboard_projects_path, notice: "You left the project." }
format.js { head :ok }
end
else
if current_user == @project.owner
message = 'You can not leave your own project. Transfer or delete the project.'
redirect_back_or_default(default: { action: 'index' }, options: { alert: message })
else
render_403
end
end
end
def apply_import
source_project = Project.find(params[:source_project_id])
......@@ -112,4 +95,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def member_params
params.require(:project_member).permit(:user_id, :access_level)
end
# MembershipActions concern
alias_method :membershipable, :project
def cannot_leave?
current_user == @project.owner
end
end
class Projects::TodosController < Projects::ApplicationController
def create
todos = TodoService.new.mark_todo(issuable, current_user)
render json: {
todo: todos,
count: current_user.todos.pending.count,
}
end
def update
current_user.todos.find_by_id(params[:id]).update(state: :done)
render json: {
count: current_user.todos.pending.count,
}
end
private
def issuable
@issuable ||= begin
case params[:issuable_type]
when "issue"
@project.issues.find(params[:issuable_id])
when "merge_request"
@project.merge_requests.find(params[:issuable_id])
end
end
end
end
......@@ -16,6 +16,9 @@ class Projects::WikisController < Projects::ApplicationController
if @page
render 'show'
elsif file = @project_wiki.find_file(params[:id], params[:version_id])
response.headers['Content-Security-Policy'] = "default-src 'none'"
response.headers['X-Content-Security-Policy'] = "default-src 'none'"
if file.on_disk?
send_file file.on_disk_path, disposition: 'inline'
else
......
......@@ -143,6 +143,7 @@ class ProjectsController < Projects::ApplicationController
issues: autocomplete.issues,
milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels,
members: participants
}
......
......@@ -40,7 +40,7 @@ class SessionsController < Devise::SessionsController
# Handle an "initial setup" state, where there's only one user, it's an admin,
# and they require a password change.
def check_initial_setup
return unless User.count == 1
return unless User.limit(2).count == 1 # Count as much 2 to know if we have exactly one
user = User.admins.last
......
......@@ -12,7 +12,7 @@ class NotesFinder
when "commit"
project.notes.for_commit_id(target_id).non_diff_notes
when "issue"
project.issues.find(target_id).notes.inc_author
project.issues.visible_to_user(current_user).find(target_id).notes.inc_author
when "merge_request"
project.merge_requests.find(target_id).mr_and_commit_notes.inc_author
when "snippet", "project_snippet"
......
......@@ -51,7 +51,7 @@ class SnippetsFinder
snippets = project.snippets.fresh
if current_user
if project.team.member?(current_user.id) || current_user.admin?
if project.team.member?(current_user) || current_user.admin?
snippets
else
snippets.public_and_internal
......
......@@ -36,7 +36,7 @@ class TodosFinder
private
def action_id?
action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED].include?(action_id.to_i)
action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED, Todo::MARKED].include?(action_id.to_i)
end
def action_id
......
......@@ -116,7 +116,7 @@ module BlobHelper
end
def blob_text_viewable?(blob)
blob && blob.text? && !blob.lfs_pointer?
blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw?
end
def blob_size(blob)
......@@ -180,8 +180,8 @@ module BlobHelper
licenses = Licensee::License.all
@licenses_for_select = {
Popular: licenses.select(&:featured).map { |license| [license.name, license.key] },
Other: licenses.reject(&:featured).map { |license| [license.name, license.key] }
Popular: licenses.select(&:featured).map { |license| { name: license.name, id: license.key } },
Other: licenses.reject(&:featured).map { |license| { name: license.name, id: license.key } }
}
end
......
......@@ -17,7 +17,15 @@ module ButtonHelper
def clipboard_button(data = {})
content_tag :button,
icon('clipboard'),
class: 'btn btn-clipboard',
class: "btn",
data: data,
type: :button
end
def clipboard_button_with_class(data = {}, css_class: 'btn-clipboard')
content_tag :button,
icon('clipboard'),
class: "btn #{css_class}",
data: data,
type: :button
end
......
......@@ -38,10 +38,10 @@ module CiStatusHelper
icon(icon_name + ' fw')
end
def render_commit_status(commit, tooltip_placement: 'auto left')
def render_commit_status(commit, tooltip_placement: 'auto left', cssclass: '')
project = commit.project
path = builds_namespace_project_commit_path(project.namespace, project, commit)
render_status_with_link('commit', commit.status, path, tooltip_placement)
render_status_with_link('commit', commit.status, path, tooltip_placement, cssclass: cssclass)
end
def render_pipeline_status(pipeline, tooltip_placement: 'auto left')
......@@ -57,10 +57,10 @@ module CiStatusHelper
private
def render_status_with_link(type, status, path, tooltip_placement)
def render_status_with_link(type, status, path, tooltip_placement, cssclass: '')
link_to ci_icon_for_status(status),
path,
class: "ci-status-link ci-status-icon-#{status.dasherize}",
class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}",
title: "#{type.titleize}: #{ci_label_for_status(status)}",
data: { toggle: 'tooltip', placement: tooltip_placement }
end
......
......@@ -16,6 +16,16 @@ module CommitsHelper
commit_person_link(commit, options.merge(source: :committer))
end
def commit_author_avatar(commit, options = {})
options = options.merge(source: :author)
user = commit.send(options[:source])
source_email = clean(commit.send "#{options[:source]}_email".to_sym)
person_email = user.try(:email) || source_email
image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]} hidden-xs", width: options[:size], alt: "")
end
def image_diff_class(diff)
if diff.deleted_file
"deleted"
......@@ -102,24 +112,24 @@ module CommitsHelper
if current_controller?(:projects, :commits)
if @repo.blob_at(commit.id, @path)
return link_to(
"Browse File »",
"Browse File",
namespace_project_blob_path(project.namespace, project,
tree_join(commit.id, @path)),
class: "pull-right"
class: "btn btn-default"
)
elsif @path.present?
return link_to(
"Browse Directory »",
"Browse Directory",
namespace_project_tree_path(project.namespace, project,
tree_join(commit.id, @path)),
class: "pull-right"
class: "btn btn-default"
)
end
end
link_to(
"Browse Files",
namespace_project_tree_path(project.namespace, project, commit),
class: "pull-right"
class: "btn btn-default"
)
end
......@@ -129,7 +139,7 @@ module CommitsHelper
tooltip = "Revert this #{commit.change_type_title} in a new merge request" if has_tooltip
if can_collaborate_with_project?
btn_class = "btn btn-grouped btn-close btn-#{btn_class}" unless btn_class.nil?
btn_class = "btn btn-warning btn-#{btn_class}" unless btn_class.nil?
link_to 'Revert', '#modal-revert-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}"
elsif can?(current_user, :fork_project, @project)
continue_params = {
......@@ -141,7 +151,7 @@ module CommitsHelper
namespace_key: current_user.namespace.id,
continue: continue_params)
btn_class = "btn btn-grouped btn-close" unless btn_class.nil?
btn_class = "btn btn-grouped btn-warning" unless btn_class.nil?
link_to 'Revert', fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip)
end
......@@ -153,7 +163,7 @@ module CommitsHelper
tooltip = "Cherry-pick this #{commit.change_type_title} in a new merge request"
if can_collaborate_with_project?
btn_class = "btn btn-default btn-grouped btn-#{btn_class}" unless btn_class.nil?
btn_class = "btn btn-default btn-#{btn_class}" unless btn_class.nil?
link_to 'Cherry-pick', '#modal-cherry-pick-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}"
elsif can?(current_user, :fork_project, @project)
continue_params = {
......@@ -187,12 +197,10 @@ module CommitsHelper
source_email = clean(commit.send "#{options[:source]}_email".to_sym)
person_name = user.try(:name) || source_name
person_email = user.try(:email) || source_email
text =
if options[:avatar]
avatar = image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "")
%Q{#{avatar} <span class="commit-#{options[:source]}-name">#{person_name}</span>}
%Q{<span class="commit-#{options[:source]}-name">#{person_name}</span>}
else
person_name
end
......
......@@ -135,6 +135,11 @@ module DiffHelper
toggle_whitespace_link(url, options)
end
def diff_compare_whitespace_link(project, from, to, options)
url = namespace_project_compare_path(project.namespace, project, from, to, params_with_whitespace)
toggle_whitespace_link(url, options)
end
private
def hide_whitespace?
......
......@@ -13,10 +13,23 @@
# merge_request_path(merge_request)
#
module GitlabRoutingHelper
# Project
def project_path(project, *args)
namespace_project_path(project.namespace, project, *args)
end
def project_url(project, *args)
namespace_project_url(project.namespace, project, *args)
end
def edit_project_path(project, *args)
edit_namespace_project_path(project.namespace, project, *args)
end
def edit_project_url(project, *args)
edit_namespace_project_url(project.namespace, project, *args)
end
def project_files_path(project, *args)
namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref)
end
......@@ -29,6 +42,10 @@ module GitlabRoutingHelper
namespace_project_pipelines_path(project.namespace, project, *args)
end
def project_environments_path(project, *args)
namespace_project_environments_path(project.namespace, project, *args)
end
def project_builds_path(project, *args)
namespace_project_builds_path(project.namespace, project, *args)
end
......@@ -41,10 +58,6 @@ module GitlabRoutingHelper
activity_namespace_project_path(project.namespace, project, *args)
end
def edit_project_path(project, *args)
edit_namespace_project_path(project.namespace, project, *args)
end
def runners_path(project, *args)
namespace_project_runners_path(project.namespace, project, *args)
end
......@@ -65,14 +78,6 @@ module GitlabRoutingHelper
namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args)
end
def project_url(project, *args)
namespace_project_url(project.namespace, project, *args)
end
def edit_project_url(project, *args)
edit_namespace_project_url(project.namespace, project, *args)
end
def issue_url(entity, *args)
namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args)
end
......@@ -92,4 +97,56 @@ module GitlabRoutingHelper
toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity)
end
end
## Members
def project_members_url(project, *args)
namespace_project_project_members_url(project.namespace, project)
end
def project_member_path(project_member, *args)
namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
def request_access_project_members_path(project, *args)
request_access_namespace_project_project_members_path(project.namespace, project)
end
def leave_project_members_path(project, *args)
leave_namespace_project_project_members_path(project.namespace, project)
end
def approve_access_request_project_member_path(project_member, *args)
approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
def resend_invite_project_member_path(project_member, *args)
resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
# Groups
## Members
def group_members_url(group, *args)
group_group_members_url(group, *args)
end
def group_member_path(group_member, *args)
group_group_member_path(group_member.source, group_member)
end
def request_access_group_members_path(group, *args)
request_access_group_group_members_path(group)
end
def leave_group_members_path(group, *args)
leave_group_group_members_path(group)
end
def approve_access_request_group_member_path(group_member, *args)
approve_access_request_group_group_member_path(group_member.source, group_member)
end
def resend_invite_group_member_path(group_member, *args)
resend_invite_group_group_member_path(group_member.source, group_member)
end
end
module GroupsHelper
def remove_user_from_group_message(group, member)
if member.user
"Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?"
else
"Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?"
end
end
def leave_group_message(group)
"Are you sure you want to leave \"#{group}\" group?"
end
def should_user_see_group_roles?(user, group)
if user
user.is_admin? || group.members.exists?(user_id: user.id)
else
false
end
end
def can_change_group_visibility_level?(group)
can?(current_user, :change_visibility_level, group)
end
......
......@@ -67,6 +67,12 @@ module IssuablesHelper
end
end
def has_todo(issuable)
unless current_user.nil?
current_user.todos.find_by(target_id: issuable.id, state: :pending)
end
end
private
def sidebar_gutter_collapsed?
......
module MembersHelper
# Returns a `<action>_<source>_member` association, e.g.:
# - admin_project_member, update_project_member, destroy_project_member
# - admin_group_member, update_group_member, destroy_group_member
def action_member_permission(action, member)
"#{action}_#{member.type.underscore}".to_sym
end
def remove_member_message(member, user: nil)
user = current_user if defined?(current_user)
text = 'Are you sure you want to '
action =
if member.request?
if member.user == user
'withdraw your access request for'
else
"deny #{member.user.name}'s request to join"
end
elsif member.invite?
"revoke the invitation for #{member.invite_email} to join"
else
"remove #{member.user.name} from"
end
text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?"
end
def remove_member_title(member)
text = " from #{member.real_source_type.humanize(capitalize: false)}"
text.prepend(member.request? ? 'Deny access request' : 'Remove user')
end
def leave_confirmation_message(member_source)
"Are you sure you want to leave the " \
"\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?"
end
end
......@@ -12,10 +12,10 @@ module NavHelper
end
def page_sidebar_class
if nav_menu_collapsed?
"page-sidebar-collapsed"
if pinned_nav?
"page-sidebar-expanded page-sidebar-pinned"
else
"page-sidebar-expanded"
"page-sidebar-collapsed"
end
end
......@@ -36,7 +36,15 @@ module NavHelper
end
def nav_header_class
class_name = " with-horizontal-nav" if defined?(nav) && nav
class_name = ''
class_name << " with-horizontal-nav" if defined?(nav) && nav
if pinned_nav?
class_name << " header-expanded header-pinned-nav"
else
class_name << " header-collapsed"
end
class_name
end
......@@ -47,4 +55,8 @@ module NavHelper
def nav_control_class
"nav-control" if current_user
end
def pinned_nav?
cookies[:pin_nav] == 'true'
end
end
module ProjectsHelper
def remove_from_project_team_message(project, member)
if member.user
"You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?"
else
"You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?"
end
end
def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
......@@ -49,7 +41,7 @@ module ProjectsHelper
author_html = author_html.html_safe
if opts[:name]
link_to(author_html, user_path(author), class: "author_link #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe
link_to(author_html, user_path(author), class: "author_link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
link_to(author_html, user_path(author), class: "author_link has-tooltip", title: title, data: { container: 'body' } ).html_safe
......@@ -115,14 +107,6 @@ module ProjectsHelper
end
end
def user_max_access_in_project(user_id, project)
level = project.team.max_member_access(user_id)
if level
Gitlab::Access.options_with_owner.key(level)
end
end
def license_short_name(project)
return 'LICENSE' if project.repository.license_key.nil?
......@@ -156,6 +140,10 @@ module ProjectsHelper
nav_tabs << :container_registry
end
if can?(current_user, :read_environment, project)
nav_tabs << :environments
end
if can?(current_user, :admin_project, project)
nav_tabs << :settings
end
......@@ -286,10 +274,6 @@ module ProjectsHelper
end
end
def leave_project_message(project)
"Are you sure you want to leave \"#{project.name}\" project?"
end
def new_readme_path
ref = @repository.root_ref if @repository
ref ||= 'master'
......
......@@ -20,7 +20,6 @@ module TimeHelper
end
end
def date_from_to(from, to)
"#{from.to_s(:short)} - #{to.to_s(:short)}"
end
......
......@@ -12,6 +12,7 @@ module TodosHelper
when Todo::ASSIGNED then 'assigned you'
when Todo::MENTIONED then 'mentioned you on'
when Todo::BUILD_FAILED then 'The build failed for your'
when Todo::MARKED then 'marked this as a Todo for'
end
end
......
module Emails
module Groups
def group_access_granted_email(group_member_id)
@group_member = GroupMember.find(group_member_id)
@group = @group_member.group
@target_url = group_url(@group)
@current_user = @group_member.user
mail(to: @group_member.user.notification_email,
subject: subject("Access to group was granted"))
end
def group_member_invited_email(group_member_id, token)
@group_member = GroupMember.find group_member_id
@group = @group_member.group
@token = token
@target_url = group_url(@group)
@current_user = @group_member.user
mail(to: @group_member.invite_email,
subject: "Invitation to join group #{@group.name}")
end
def group_invite_accepted_email(group_member_id)
@group_member = GroupMember.find group_member_id
return if @group_member.created_by.nil?
@group = @group_member.group
@target_url = group_url(@group)
@current_user = @group_member.created_by
mail(to: @group_member.created_by.notification_email,
subject: subject("Invitation accepted"))
end
def group_invite_declined_email(group_id, invite_email, access_level, created_by_id)
return if created_by_id.nil?
@group = Group.find(group_id)
@current_user = @created_by = User.find(created_by_id)
@access_level = access_level
@invite_email = invite_email
@target_url = group_url(@group)
mail(to: @created_by.notification_email,
subject: subject("Invitation declined"))
end
end
end
module Emails
module Members
extend ActiveSupport::Concern
include MembersHelper
included do
helper_method :member_source, :member
end
def member_access_requested_email(member_source_type, member_id)
@member_source_type = member_source_type
@member_id = member_id
admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email)
mail(to: admins,
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
end
def member_access_granted_email(member_source_type, member_id)
@member_source_type = member_source_type
@member_id = member_id
mail(to: member.user.notification_email,
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted"))
end
def member_access_denied_email(member_source_type, source_id, user_id)
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
requester = User.find(user_id)
mail(to: requester.notification_email,
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied"))
end
def member_invited_email(member_source_type, member_id, token)
@member_source_type = member_source_type
@member_id = member_id
@token = token
mail(to: member.invite_email,
subject: "Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")
end
def member_invite_accepted_email(member_source_type, member_id)
@member_source_type = member_source_type
@member_id = member_id
return unless member.created_by
mail(to: member.created_by.notification_email,
subject: subject('Invitation accepted'))
end
def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id)
return unless created_by_id
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
@invite_email = invite_email
inviter = User.find(created_by_id)
mail(to: inviter.notification_email,
subject: subject('Invitation declined'))
end
def member
@member ||= Member.find(@member_id)
end
def member_source
@member_source ||= member.source
end
private
def member_source_class
@member_source_type.classify.constantize
end
end
end
module Emails
module Projects
def project_access_granted_email(project_member_id)
@project_member = ProjectMember.find project_member_id
@project = @project_member.project
@target_url = namespace_project_url(@project.namespace, @project)
@current_user = @project_member.user
mail(to: @project_member.user.notification_email,
subject: subject("Access to project was granted"))
end
def project_member_invited_email(project_member_id, token)
@project_member = ProjectMember.find project_member_id
@project = @project_member.project
@token = token
@target_url = namespace_project_url(@project.namespace, @project)
@current_user = @project_member.user
mail(to: @project_member.invite_email,
subject: "Invitation to join project #{@project.name_with_namespace}")
end
def project_invite_accepted_email(project_member_id)
@project_member = ProjectMember.find project_member_id
return if @project_member.created_by.nil?
@project = @project_member.project
@target_url = namespace_project_url(@project.namespace, @project)
@current_user = @project_member.created_by
mail(to: @project_member.created_by.notification_email,
subject: subject("Invitation accepted"))
end
def project_invite_declined_email(project_id, invite_email, access_level, created_by_id)
return if created_by_id.nil?
@project = Project.find(project_id)
@current_user = @created_by = User.find(created_by_id)
@access_level = access_level
@invite_email = invite_email
@target_url = namespace_project_url(@project.namespace, @project)
mail(to: @created_by.notification_email,
subject: subject("Invitation declined"))
end
def project_was_moved_email(project_id, user_id, old_path_with_namespace)
@current_user = @user = User.find user_id
@project = Project.find project_id
......
......@@ -6,13 +6,15 @@ class Notify < BaseMailer
include Emails::Notes
include Emails::Projects
include Emails::Profile
include Emails::Groups
include Emails::Builds
include Emails::Members
add_template_helper MergeRequestsHelper
add_template_helper DiffHelper
add_template_helper BlobHelper
add_template_helper EmailsHelper
add_template_helper MembersHelper
add_template_helper GitlabRoutingHelper
def test_email(recipient_email, subject, body)
mail(to: recipient_email,
......
......@@ -9,7 +9,6 @@ class Ability
when CommitStatus then commit_status_abilities(user, subject)
when Project then project_abilities(user, subject)
when Issue then issue_abilities(user, subject)
when ExternalIssue then external_issue_abilities(user, subject)
when Note then note_abilities(user, subject)
when ProjectSnippet then project_snippet_abilities(user, subject)
when PersonalSnippet then personal_snippet_abilities(user, subject)
......@@ -19,6 +18,7 @@ class Ability
when GroupMember then group_member_abilities(user, subject)
when ProjectMember then project_member_abilities(user, subject)
when User then user_abilities
when ExternalIssue, Deployment, Environment then project_abilities(user, subject.project)
else []
end.concat(global_abilities(user))
end
......@@ -187,6 +187,8 @@ class Ability
project_report_rules
elsif team.guest?(user)
project_guest_rules
else
[]
end
end
......@@ -228,6 +230,8 @@ class Ability
:read_build,
:read_container_image,
:read_pipeline,
:read_environment,
:read_deployment
]
end
......@@ -246,6 +250,8 @@ class Ability
:push_code,
:create_container_image,
:update_container_image,
:create_environment,
:create_deployment
]
end
......@@ -263,6 +269,8 @@ class Ability
@project_master_rules ||= project_dev_rules + [
:push_code_to_protected_branches,
:update_project_snippet,
:update_environment,
:update_deployment,
:admin_milestone,
:admin_project_snippet,
:admin_project_member,
......@@ -273,7 +281,9 @@ class Ability
:admin_commit_status,
:admin_build,
:admin_container_image,
:admin_pipeline
:admin_pipeline,
:admin_environment,
:admin_deployment
]
end
......@@ -317,6 +327,8 @@ class Ability
unless project.builds_enabled
rules += named_abilities('build')
rules += named_abilities('pipeline')
rules += named_abilities('environment')
rules += named_abilities('deployment')
end
unless project.container_registry_enabled
......@@ -511,10 +523,6 @@ class Ability
end
end
def external_issue_abilities(user, subject)
project_abilities(user, subject.project)
end
private
def restricted_public_level?
......@@ -533,7 +541,7 @@ class Ability
def filter_confidential_issues_abilities(user, issue, rules)
return rules if user.admin? || !issue.confidential?
unless issue.author == user || issue.assignee == user || issue.project.team.member?(user.id)
unless issue.author == user || issue.assignee == user || issue.project.team.member?(user, Gitlab::Access::REPORTER)
rules.delete(:admin_issue)
rules.delete(:read_issue)
rules.delete(:update_issue)
......
......@@ -24,7 +24,7 @@ class Blob < SimpleDelegator
end
def only_display_raw?
size && size > 5.megabytes
size && truncated?
end
def svg?
......
......@@ -11,6 +11,8 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
scope :with_artifacts, ->() { where.not(artifacts_file: nil) }
scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
mount_uploader :artifacts_file, ArtifactUploader
mount_uploader :artifacts_metadata, ArtifactUploader
......@@ -38,7 +40,7 @@ module Ci
new_build.save
end
def retry(build)
def retry(build, user = nil)
new_build = Ci::Build.new(status: 'pending')
new_build.ref = build.ref
new_build.tag = build.tag
......@@ -52,6 +54,7 @@ module Ci
new_build.stage = build.stage
new_build.stage_idx = build.stage_idx
new_build.trigger_request = build.trigger_request
new_build.user = user
new_build.save
MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build)
new_build
......@@ -73,6 +76,17 @@ module Ci
build.update_coverage
build.execute_hooks
end
after_transition any => [:success] do |build|
if build.environment.present?
service = CreateDeploymentService.new(build.project, build.user,
environment: build.environment,
sha: build.sha,
ref: build.ref,
tag: build.tag)
service.execute(build)
end
end
end
def retryable?
......@@ -83,10 +97,6 @@ module Ci
!self.pipeline.statuses.latest.include?(self)
end
def retry
Ci::Build.retry(self)
end
def depends_on_builds
# Get builds of the same type
latest_builds = self.pipeline.builds.latest
......@@ -311,7 +321,7 @@ module Ci
end
def artifacts?
artifacts_file.exists?
!artifacts_expired? && artifacts_file.exists?
end
def artifacts_metadata?
......@@ -322,11 +332,15 @@ module Ci
Gitlab::Ci::Build::Artifacts::Metadata.new(artifacts_metadata.path, path, **options).to_entry
end
def erase_artifacts!
remove_artifacts_file!
remove_artifacts_metadata!
end
def erase(opts = {})
return false unless erasable?
remove_artifacts_file!
remove_artifacts_metadata!
erase_artifacts!
erase_trace!
update_erased!(opts[:erased_by])
end
......@@ -339,6 +353,25 @@ module Ci
!self.erased_at.nil?
end
def artifacts_expired?
artifacts_expire_at && artifacts_expire_at < Time.now
end
def artifacts_expire_in
artifacts_expire_at - Time.now if artifacts_expire_at
end
def artifacts_expire_in=(value)
self.artifacts_expire_at =
if value
Time.now + ChronicDuration.parse(value)
end
end
def keep_artifacts!
self.update(artifacts_expire_at: nil)
end
private
def erase_trace!
......@@ -346,7 +379,7 @@ module Ci
end
def update_erased!(user = nil)
self.update(erased_by: user, erased_at: Time.now)
self.update(erased_by: user, erased_at: Time.now, artifacts_expire_at: nil)
end
def yaml_variables
......
......@@ -76,8 +76,10 @@ module Ci
builds.running_or_pending.each(&:cancel)
end
def retry_failed
builds.latest.failed.select(&:retryable?).each(&:retry)
def retry_failed(user)
builds.latest.failed.select(&:retryable?).each do |build|
Ci::Build.retry(build, user)
end
end
def latest?
......@@ -92,10 +94,13 @@ module Ci
end
def create_builds(user, trigger_request = nil)
##
# We persist pipeline only if there are builds available
#
return unless config_processor
config_processor.stages.any? do |stage|
CreateBuildsService.new(self).execute(stage, user, 'success', trigger_request).present?
end
build_builds_for_stages(config_processor.stages, user,
'success', trigger_request) && save
end
def create_next_builds(build)
......@@ -113,10 +118,10 @@ module Ci
prior_builds = latest_builds.where.not(stage: next_stages)
prior_status = prior_builds.status
# create builds for next stages based
next_stages.any? do |stage|
CreateBuildsService.new(self).execute(stage, build.user, prior_status, build.trigger_request).present?
end
# build builds for next stage that has builds available
# and save pipeline if we have builds
build_builds_for_stages(next_stages, build.user, prior_status,
build.trigger_request) && save
end
def retried
......@@ -137,10 +142,10 @@ module Ci
@config_processor ||= begin
Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace)
rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e
save_yaml_error(e.message)
self.yaml_errors = e.message
nil
rescue
save_yaml_error("Undefined error")
self.yaml_errors = 'Undefined error'
nil
end
end
......@@ -161,8 +166,23 @@ module Ci
git_commit_message =~ /(\[ci skip\])/ if git_commit_message
end
def environments
builds.where.not(environment: nil).success.pluck(:environment).uniq
end
private
def build_builds_for_stages(stages, user, status, trigger_request)
##
# Note that `Array#any?` implements a short circuit evaluation, so we
# build builds only for the first stage that has builds available.
#
stages.any? do |stage|
CreateBuildsService.new(self)
.execute(stage, user, status, trigger_request).present?
end
end
def update_state
statuses.reload
self.status = if yaml_errors.blank?
......@@ -175,11 +195,5 @@ module Ci
self.duration = statuses.latest.duration
save
end
def save_yaml_error(error)
return if self.yaml_errors?
self.yaml_errors = error
update_state
end
end
end
# == AccessRequestable concern
#
# Contains functionality related to objects that can receive request for access.
#
# Used by Project, and Group.
#
module AccessRequestable
extend ActiveSupport::Concern
def request_access(user)
members.create(
access_level: Gitlab::Access::DEVELOPER,
user: user,
requested_at: Time.now.utc)
end
end
......@@ -5,7 +5,7 @@ module Awardable
has_many :award_emoji, as: :awardable, dependent: :destroy
if self < Participable
participant :award_emoji
participant :award_emoji_with_associations
end
end
......@@ -34,8 +34,12 @@ module Awardable
end
end
def award_emoji_with_associations
award_emoji.includes(:user)
end
def grouped_awards(with_thumbs: true)
awards = award_emoji.group_by(&:name)
awards = award_emoji_with_associations.group_by(&:name)
if with_thumbs
awards[AwardEmoji::UPVOTE_NAME] ||= []
......
class Deployment < ActiveRecord::Base
include InternalId
belongs_to :project, required: true, validate: true
belongs_to :environment, required: true, validate: true
belongs_to :user
belongs_to :deployable, polymorphic: true
validates :sha, presence: true
validates :ref, presence: true
delegate :name, to: :environment, prefix: true
def commit
project.commit(sha)
end
def commit_title
commit.try(:title)
end
def short_sha
Commit.truncate_sha(sha)
end
def last?
self == environment.last_deployment
end
end
class Environment < ActiveRecord::Base
belongs_to :project, required: true, validate: true
has_many :deployments
validates :name,
presence: true,
uniqueness: { scope: :project_id },
length: { within: 0..255 },
format: { with: Gitlab::Regex.environment_name_regex,
message: Gitlab::Regex.environment_name_regex_message }
def last_deployment
deployments.last
end
end
......@@ -3,11 +3,18 @@ require 'carrierwave/orm/activerecord'
class Group < Namespace
include Gitlab::ConfigHelper
include Gitlab::VisibilityLevel
include AccessRequestable
include Referable
has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember'
alias_method :members, :group_members
has_many :users, through: :group_members
has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members
has_many :owners,
-> { where(members: { access_level: Gitlab::Access::OWNER }) },
through: :group_members,
source: :user
has_many :project_group_links, dependent: :destroy
has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source
......@@ -58,6 +65,10 @@ class Group < Namespace
"#{self.class.reference_prefix}#{name}"
end
def web_url
Gitlab::Routing.url_helpers.group_url(self)
end
def human_name
name
end
......@@ -83,10 +94,6 @@ class Group < Namespace
end
end
def owners
@owners ||= group_members.owners.includes(:user).map(&:user)
end
def add_users(user_ids, access_level, current_user = nil)
user_ids.each do |user_id|
Member.add_user(self.group_members, user_id, access_level, current_user)
......
......@@ -51,10 +51,18 @@ class Issue < ActiveRecord::Base
end
def self.visible_to_user(user)
return where(confidential: false) if user.blank?
return where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
return all if user.admin?
where('issues.confidential = false OR (issues.confidential = true AND (issues.author_id = :user_id OR issues.assignee_id = :user_id OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects.select(:id))
where('
issues.confidential IS NULL
OR issues.confidential IS FALSE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR issues.assignee_id = :user_id
OR issues.project_id IN(:project_ids)))',
user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
end
def self.reference_prefix
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment