Commit 5c81fc43 authored by Alfredo Sumaran's avatar Alfredo Sumaran

Merge branch 'master' into issue_14678

# Conflicts:
#	app/assets/javascripts/todos.js.coffee
parents 5c307cf1 79467437
......@@ -4,14 +4,37 @@ v 8.7.0 (unreleased)
- Don't attempt to look up an avatar in repo if repo directory does not exist (Stan hu)
- Preserve time notes/comments have been updated at when moving issue
- Make HTTP(s) label consistent on clone bar (Stan Hu)
- Allow back dating on issues when created through the API
- Expose label description in API (Mariusz Jachimowicz)
- Allow back dating on issues when created through the API
- Fix avatar stretching by providing a cropping feature
- Fix raw/rendered diff producing different results on merge requests !3450
- Add links to CI setup documentation from project settings and builds pages
- Handle nil descriptions in Slack issue messages (Stan Hu)
- Implement 'Groups View' as an option for dashboard preferences !3379 (Elias W.)
- Implement 'TODOs View' as an option for dashboard preferences !3379 (Elias W.)
v 8.6.2 (unreleased)
- Comments on confidential issues don't show up in activity feed to non-members
- Gracefully handle notes on deleted commits in merge requests (Stan Hu)
v 8.6.2
- Fix dropdown alignment. !3298
- Fix issuable sidebar overlaps on tablet. !3299
- Make dropdowns pixel perfect. !3337
- Fix order of steps to prevent PostgreSQL errors when running migration. !3355
- Fix bold text in issuable sidebar. !3358
- Fix error with anonymous token in applications settings. !3362
- Fix the milestone 'upcoming' filter. !3364 + !3368
- Fix comments on confidential issues showing up in activity feed to non-members. !3375
- Fix `NoMethodError` when visiting CI root path at `/ci`. !3377
- Add a tooltip to new branch button in issue page. !3380
- Fix an issue hiding the password form when signed-in with a linked account. !3381
- Add links to CI setup documentation from project settings and builds pages. !3384
- Fix an issue with width of project select dropdown. !3386
- Remove redundant `require`s from Banzai files. !3391
- Fix error 500 with cancel button on issuable edit form. !3392 + !3417
- Fix background when editing a highlighted note. !3423
- Remove tabstop from the WIP toggle links. !3426
- Ensure private project snippets are not viewable by unauthorized people.
- Gracefully handle notes on deleted commits in merge requests (Stan Hu). !3402
- Fixed issue with notification settings not saving. !3452
v 8.6.1
- Add option to reload the schema before restoring a database backup. !2807
......@@ -51,6 +74,7 @@ v 8.6.0
- Fix wiki search results point to raw source (Hiroyuki Sato)
- Don't load all of GitLab in mail_room
- Add information about `image` and `services` field at `job` level in the `.gitlab-ci.yml` documentation (Pat Turner)
- Fall back to `In-Reply-To` and `References` headers when sub-addressing is not available (David Padilla)
- HTTP error pages work independently from location and config (Artem Sidorenko)
- Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set
- Memoize @group in Admin::GroupsController (Yatish Mehta)
......
......@@ -126,8 +126,10 @@ class GitLabDropdown
@selectFirstRow()
# Event listeners
@dropdown.on "shown.bs.dropdown", @opened
@dropdown.on "hidden.bs.dropdown", @hidden
@dropdown.on "click", ".dropdown-menu, .dropdown-menu-close", @shouldPropagate
if @dropdown.find(".dropdown-toggle-page").length
@dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on "click", (e) =>
......@@ -143,10 +145,10 @@ class GitLabDropdown
selector = ".dropdown-page-one .dropdown-content a"
@dropdown.on "click", selector, (e) ->
self.rowClicked $(@)
selected = self.rowClicked $(@)
if self.options.clicked
self.options.clicked()
self.options.clicked(selected)
toggleLoading: ->
$('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS
......@@ -176,6 +178,15 @@ class GitLabDropdown
@appendMenu(full_html)
shouldPropagate: (e) =>
if @options.multiSelect
$target = $(e.target)
if not $target.hasClass('dropdown-menu-close') and not $target.hasClass('dropdown-menu-close-icon')
e.stopPropagation()
return false
else
return true
opened: =>
contentHtml = $('.dropdown-content', @dropdown).html()
if @remote && contentHtml is ""
......@@ -184,7 +195,9 @@ class GitLabDropdown
if @options.filterable
@dropdown.find(".dropdown-input-field").focus()
hidden: =>
@dropdown.trigger('shown.gl.dropdown')
hidden: (e) =>
if @options.filterable
@dropdown
.find(".dropdown-input-field")
......@@ -195,6 +208,11 @@ class GitLabDropdown
if @dropdown.find(".dropdown-toggle-page").length
$('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS
if @options.hidden
@options.hidden.call(@,e)
@dropdown.trigger('hidden.gl.dropdown')
# Render the full menu
renderMenu: (html) ->
......@@ -226,6 +244,13 @@ class GitLabDropdown
html = @options.renderRow(data)
else
selected = if @options.isSelected then @options.isSelected(data) else false
if not selected
value = if @options.id then @options.id(data) else data.id
fieldName = @options.fieldName
field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']")
if field.length
selected = true
url = if @options.url then @options.url(data) else "#"
text = if @options.text then @options.text(data) else ""
cssClass = "";
......@@ -258,41 +283,43 @@ class GitLabDropdown
rowClicked: (el) ->
fieldName = @options.fieldName
field = @dropdown.parent().find("input[name='#{fieldName}']")
selectedIndex = el.parent().index()
if @renderedData
selectedObject = @renderedData[selectedIndex]
value = if @options.id then @options.id(selectedObject, el) else selectedObject.id
field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']")
if el.hasClass(ACTIVE_CLASS)
el.removeClass(ACTIVE_CLASS)
field.remove()
else
fieldName = @options.fieldName
selectedIndex = el.parent().index()
if @renderedData
selectedObject = @renderedData[selectedIndex]
value = if @options.id then @options.id(selectedObject, el) else selectedObject.id
# Toggle the dropdown label
if @options.toggleLabel
$(@el).find(".dropdown-toggle-text").text @options.toggleLabel
else
if !value?
field.remove()
if @options.multiSelect
oldValue = field.val()
if oldValue
value = "#{oldValue},#{value}"
else
if not @options.multiSelect
@dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS
@dropdown.parent().find("input[name='#{fieldName}']").remove()
# Toggle active class for the tick mark
el.toggleClass "is-active"
el.addClass ACTIVE_CLASS
# Toggle the dropdown label
if @options.toggleLabel
$(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selectedObject)
if value?
if !field.length
# Create hidden input for form
input = "<input type='hidden' name='#{fieldName}' />"
input = "<input type='hidden' name='#{fieldName}' value='#{value}' />"
if @options.inputId?
input = $(input)
.attr('id', @options.inputId)
@dropdown.before input
@dropdown.parent().find("input[name='#{fieldName}']").val value
return selectedObject
selectFirstRow: ->
selector = '.dropdown-content li:first-child a'
......
class @IssuableContext
constructor: ->
constructor: (currentUser) ->
@initParticipants()
new UsersSelect()
new UsersSelect(currentUser)
$('select.select2').select2({width: 'resolve', dropdownAutoWidth: true})
$(".issuable-sidebar .inline-update").on "change", "select", ->
......@@ -10,11 +9,21 @@ class @IssuableContext
$(".issuable-sidebar .inline-update").on "change", ".js-assignee", ->
$(this).submit()
$(document).on "click",".edit-link", (e) ->
block = $(@).parents('.block')
block.find('.selectbox').show()
block.find('.value').hide()
block.find('.js-select2').select2("open")
$(document).off("click", ".edit-link").on "click",".edit-link", (e) ->
$block = $(@).parents('.block')
$selectbox = $block.find('.selectbox')
if $selectbox.is(':visible')
$selectbox.hide()
$block.find('.value').show()
else
$selectbox.show()
$block.find('.value').hide()
if $selectbox.is(':visible')
setTimeout (->
$block.find('.dropdown-menu-toggle').trigger 'click'
), 0
$(".right-sidebar").niceScroll()
......
@Issues =
init: ->
Issues.initSearch()
Issues.initSelects()
Issues.initChecks()
$("body").on "ajax:success", ".close_issue, .reopen_issue", ->
......@@ -17,18 +16,9 @@
$(this).html totalIssues - 1
reload: ->
Issues.initSelects()
Issues.initChecks()
$('#filter_issue_search').val($('#issue_search').val())
initSelects: ->
$("select#update_state_event").select2(width: 'resolve', dropdownAutoWidth: true)
$("select#update_assignee_id").select2(width: 'resolve', dropdownAutoWidth: true)
$("select#update_milestone_id").select2(width: 'resolve', dropdownAutoWidth: true)
$("select#label_name").select2(width: 'resolve', dropdownAutoWidth: true)
$("#milestone_id, #assignee_id, #label_name").on "change", ->
$(this).closest("form").submit()
initChecks: ->
$(".check_all_issues").click ->
$(".selected_issue").prop("checked", @checked)
......
......@@ -4,14 +4,21 @@ class @LabelsSelect
$dropdown = $(dropdown)
projectId = $dropdown.data('project-id')
labelUrl = $dropdown.data('labels')
issueUpdateURL = $dropdown.data('issueUpdate')
selectedLabel = $dropdown.data('selected')
if selectedLabel
selectedLabel = selectedLabel.toString().split(',')
if selectedLabel?
selectedLabel = selectedLabel.split(',')
newLabelField = $('#new_label_name')
newColorField = $('#new_label_color')
showNo = $dropdown.data('show-no')
showAny = $dropdown.data('show-any')
defaultLabel = $dropdown.data('default-label')
abilityName = $dropdown.data('ability-name')
$selectbox = $dropdown.closest('.selectbox')
$block = $selectbox.closest('.block')
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span')
$value = $block.find('.value')
$loading = $block.find('.block-loading').fadeOut()
if newLabelField.length
$newLabelCreateButton = $('.js-new-label-btn')
......@@ -21,6 +28,22 @@ class @LabelsSelect
# Suggested colors in the dropdown to chose from pre-chosen colors
$('.suggest-colors-dropdown a').on 'click', (e) ->
issueURLSplit = issueUpdateURL.split('/') if issueUpdateURL?
if issueUpdateURL
labelHTMLTemplate = _.template(
'<% _.each(labels, function(label){ %>
<a href="<%= ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name=<%= label.title %>">
<span class="label color-label" style="background-color: <%= label.color %>;">
<%= label.title %>
</span>
</a>
<% }); %>'
);
labelNoneHTMLTemplate = _.template('<div class="light">None</div>')
if newLabelField.length and $dropdown.hasClass 'js-extra-options'
$('.suggest-colors-dropdown a').on "click", (e) ->
e.preventDefault()
e.stopPropagation()
newColorField
......@@ -57,6 +80,23 @@ class @LabelsSelect
# This allows us to enable the button when ready
enableLabelCreateButton = ->
if newLabelField.val() isnt '' and newColorField.val() isnt ''
$newLabelError.hide()
$('.js-new-label-btn').disable()
# Create new label with API
Api.newLabel projectId, {
name: newLabelField.val()
color: newColorField.val()
}, (label) ->
$('.js-new-label-btn').enable()
if label.message?
$newLabelError
.text label.message
.show()
else
$('.dropdown-menu-back', $dropdown.parent()).trigger 'click'
$newLabelCreateButton.enable()
else
$newLabelCreateButton.disable()
......@@ -90,41 +130,84 @@ class @LabelsSelect
else
$('.dropdown-menu-back', $dropdown.parent()).trigger 'click'
saveLabelData = ->
selected = $dropdown
.closest('.selectbox')
.find("input[name='#{$dropdown.data('field-name')}']")
.map(->
@value
).get()
data = {}
data[abilityName] = {}
data[abilityName].label_ids = selected
if not selected.length
data[abilityName].label_ids = ['']
$loading.fadeIn()
$dropdown.trigger('loading.gl.dropdown')
$.ajax(
type: 'PUT'
url: issueUpdateURL
dataType: 'JSON'
data: data
).done (data) ->
$loading.fadeOut()
$dropdown.trigger('loaded.gl.dropdown')
$selectbox.hide()
data.issueURLSplit = issueURLSplit
labelCount = 0
if data.labels.length
template = labelHTMLTemplate(data)
labelCount = data.labels.length
else
template = labelNoneHTMLTemplate()
$value
.removeAttr('style')
.html(template)
$sidebarCollapsedValue.text(labelCount)
$value
.find('a')
.each((i) ->
setTimeout(=>
glAnimate($(@), 'pulse')
,200 * i
)
)
$dropdown.glDropdown(
data: (term, callback) ->
$.ajax(
url: labelUrl
).done (data) ->
if showNo
data.unshift(
id: 0
title: 'No Label'
)
if $dropdown.hasClass 'js-extra-options'
if showNo
data.unshift(
id: 0
title: 'No Label'
)
if showAny
data.unshift(
isAny: true
title: 'Any Label'
)
if data.length > 2
data.splice 2, 0, 'divider'
if showAny
data.unshift(
isAny: true
title: 'Any Label'
)
if data.length > 2
data.splice 2, 0, 'divider'
callback data
renderRow: (label) ->
if $.isArray(selectedLabel)
selected = ''
$.each selectedLabel, (i, selectedLbl) ->
selectedLbl = selectedLbl.trim()
if selected is '' and label.title is selectedLbl
selected = 'is-active'
else
selected = if label.title is selectedLabel then 'is-active' else ''
selectedClass = ''
if $selectbox.find("input[type='hidden']\
[name='#{$dropdown.data('field-name')}']\
[value='#{label.id}']").length
selectedClass = 'is-active'
color = if label.color? then "<span class='dropdown-label-box' style='background-color: #{label.color}'></span>" else ""
"<li>
<a href='#' class='#{selected}'>
<a href='#' class='#{selectedClass}'>
#{color}
#{label.title}
</a>
......@@ -133,6 +216,7 @@ class @LabelsSelect
search:
fields: ['title']
selectable: true
toggleLabel: (selected) ->
if selected and selected.title isnt 'Any Label'
selected.title
......@@ -142,15 +226,33 @@ class @LabelsSelect
id: (label) ->
if label.isAny?
''
else
else if $dropdown.hasClass "js-filter-submit"
label.title
clicked: ->
else
label.id
hidden: ->
$selectbox.hide()
# display:block overrides the hide-collapse rule
$value.removeAttr('style')
if $dropdown.hasClass 'js-multiselect'
saveLabelData()
multiSelect: $dropdown.hasClass 'js-multiselect'
clicked: (label) ->
page = $('body').data 'page'
isIssueIndex = page is 'projects:issues:index'
isMRIndex = page is page is 'projects:merge_requests:index'
if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex)
selectedLabel = label.title
Issues.filterResults $dropdown.closest('form')
else if $dropdown.hasClass 'js-filter-submit'
$dropdown.closest('form').submit()
else
if $dropdown.hasClass 'js-multiselect'
return
else
saveLabelData()
)
((w) ->
w.glAnimate = ($el, animation, done) ->
$el
.removeClass()
.addClass(animation + ' animated')
.one 'webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', ->
$(this).removeClass()
return
return
return
) window
\ No newline at end of file
class @MilestoneSelect
constructor: ->
constructor: (currentProject) ->
if currentProject?
_this = @
@currentProject = JSON.parse(currentProject)
$('.js-milestone-select').each (i, dropdown) ->
$dropdown = $(dropdown)
projectId = $dropdown.data('project-id')
milestonesUrl = $dropdown.data('milestones')
issueUpdateURL = $dropdown.data('issueUpdate')
selectedMilestone = $dropdown.data('selected')
showNo = $dropdown.data('show-no')
showAny = $dropdown.data('show-any')
showUpcoming = $dropdown.data('show-upcoming')
useId = $dropdown.data('use-id')
defaultLabel = $dropdown.data('default-label')
issuableId = $dropdown.data('issuable-id')
abilityName = $dropdown.data('ability-name')
$selectbox = $dropdown.closest('.selectbox')
$block = $selectbox.closest('.block')
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon')
$value = $block.find('.value')
$loading = $block.find('.block-loading').fadeOut()
if issueUpdateURL
milestoneLinkTemplate = _.template(
'<a href="/<%= namespace %>/<%= path %>/milestones/<%= iid %>"><%= title %></a>'
)
milestoneLinkNoneTemplate = '<div class="light">None</div>'
$dropdown.glDropdown(
data: (term, callback) ->
$.ajax(
url: milestonesUrl
).done (data) ->
extraOptions = []
if showAny
extraOptions.push(
id: 0
name: ''
title: 'Any Milestone'
)
if showNo
data.unshift(
id: '0'
extraOptions.push(
id: -1
name: 'No Milestone'
title: 'No Milestone'
)
if showAny
data.unshift(
isAny: true
title: 'Any Milestone'
if showUpcoming
extraOptions.push(
id: -2
name: '#upcoming'
title: 'Upcoming'
)
if data.length > 2
data.splice 2, 0, 'divider'
if extraOptions.length > 2
extraOptions.push 'divider'
callback(data)
callback(extraOptions.concat(data))
filterable: true
search:
fields: ['title']
......@@ -45,21 +74,47 @@ class @MilestoneSelect
milestone.title
id: (milestone) ->
if !useId
if !milestone.isAny?
milestone.title
else
''
milestone.name
else
milestone.id
isSelected: (milestone) ->
milestone.title is selectedMilestone
clicked: ->
page = $('body').data 'page'
isIssueIndex = page is 'projects:issues:index'
isMRIndex = page is page is 'projects:merge_requests:index'
milestone.name is selectedMilestone
hidden: ->
$selectbox.hide()
if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex)
Issues.filterResults $dropdown.closest('form')
else if $dropdown.hasClass 'js-filter-submit'
$dropdown.closest('form').submit()
)
# display:block overrides the hide-collapse rule
$value.removeAttr('style')
clicked: (e) ->
if $dropdown.hasClass 'js-filter-bulk-update'
return
if $dropdown.hasClass 'js-filter-submit'
$dropdown.parents('form').submit()
else
selected = $selectbox
.find('input[type="hidden"]')
.val()
data = {}
data[abilityName] = {}
data[abilityName].milestone_id = selected
$loading
.fadeIn()
$dropdown.trigger('loading.gl.dropdown')
$.ajax(
type: 'PUT'
url: issueUpdateURL
data: data
).done (data) ->
$dropdown.trigger('loaded.gl.dropdown')
$loading.fadeOut()
$selectbox.hide()
$value.removeAttr('style')
if data.milestone?
data.milestone.namespace = _this.currentProject.namespace
data.milestone.path = _this.currentProject.path
$value.html(milestoneLinkTemplate(data.milestone))
$sidebarCollapsedValue.find('span').text(data.milestone.title)
else
$value.html(milestoneLinkNoneTemplate)
$sidebarCollapsedValue.find('span').text('No')
)
\ No newline at end of file
class @Sidebar
constructor: (currentUser) ->
@addEventListeners()
addEventListeners: ->
$('aside').on('click', '.sidebar-collapsed-icon', @sidebarCollapseClicked)
$('.dropdown').on('hidden.gl.dropdown', @sidebarDropdownHidden)
$('.dropdown').on('loading.gl.dropdown', @sidebarDropdownLoading)
$('.dropdown').on('loaded.gl.dropdown', @sidebarDropdownLoaded)
sidebarDropdownLoading: (e) ->
$sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon')
img = $sidebarCollapsedIcon.find('img')
i = $sidebarCollapsedIcon.find('i')
$loading = $('<i class="fa fa-spinner fa-spin"></i>')
if img.length
img.before($loading)
img.hide()
else if i.length
i.before($loading)
i.hide()
sidebarDropdownLoaded: (e) ->
$sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon')
img = $sidebarCollapsedIcon.find('img')
$sidebarCollapsedIcon.find('i.fa-spin').remove()
i = $sidebarCollapsedIcon.find('i')
if img.length
img.show()
else
i.show()
sidebarCollapseClicked: (e) ->
e.preventDefault()
$block = $(@).closest('.block')
$('aside')
.find('.gutter-toggle')
.trigger('click')
$editLink = $block.find('.edit-link')
if $editLink.length
$editLink.trigger('click')
$block.addClass('collapse-after-update')
$('.page-with-sidebar').addClass('with-overlay')
sidebarDropdownHidden: (e) ->
$block = $(@).closest('.block')
if $block.hasClass('collapse-after-update')
$block.removeClass('collapse-after-update')
$('.page-with-sidebar').removeClass('with-overlay')
$('aside')
.find('.gutter-toggle')
.trigger('click')
\ No newline at end of file
......@@ -8,10 +8,12 @@ class @Todos
clearListeners: ->
$('.done-todo').off('click')
$('.js-todos-mark-all').off('click')
$('.todo').off('click')
initBtnListeners: ->
$('.done-todo').on('click', @doneClicked)
$('.js-todos-mark-all').on('click', @allDoneClicked)
$('.todo').on('click', @goToTodoUrl)
doneClicked: (e) =>
e.preventDefault()
......@@ -97,3 +99,6 @@ class @Todos
return uri.replace(regex, '$1' + key + '=' + value + '$2')
uri + separator + key + '=' + value
goToTodoUrl: ->
Turbolinks.visit($(this).data('url'))
class @UsersSelect
constructor: ->
constructor: (currentUser) ->
@usersPath = "/autocomplete/users.json"
@userPath = "/autocomplete/users/:id.json"
if currentUser?
@currentUser = JSON.parse(currentUser)
$('.js-user-search').each (i, dropdown) =>
$dropdown = $(dropdown)
......@@ -12,6 +14,81 @@ class @UsersSelect
firstUser = $dropdown.data('first-user')
selectedId = $dropdown.data('selected')
defaultLabel = $dropdown.data('default-label')
issueURL = $dropdown.data('issueUpdate')
$selectbox = $dropdown.closest('.selectbox')
$block = $selectbox.closest('.block')
abilityName = $dropdown.data('ability-name')
$value = $block.find('.value')
$collapsedSidebar = $block.find('.sidebar-collapsed-user')
$loading = $block.find('.block-loading').fadeOut()
$block.on('click', '.js-assign-yourself', (e) =>
e.preventDefault()
assignTo(@currentUser.id)
)
assignTo = (selected) ->
data = {}
data[abilityName] = {}
data[abilityName].assignee_id = selected
$loading
.fadeIn()
$dropdown.trigger('loading.gl.dropdown')
$.ajax(
type: 'PUT'
dataType: 'json'
url: issueURL
data: data
).done (data) ->
$dropdown.trigger('loaded.gl.dropdown')
$loading.fadeOut()
$selectbox.hide()
if data.assignee
user =
name: data.assignee.name
username: data.assignee.username
avatar: data.assignee.avatar_url
else
user =
name: 'Unassigned'
username: ''
avatar: ''
$value.html(assigneeTemplate(user))
$collapsedSidebar.html(collapsedAssigneeTemplate(user))
collapsedAssigneeTemplate = _.template(
'<% if( avatar ) { %>
<a class="author_link" href="/u/<%= username %>">
<img width="24" class="avatar avatar-inline s24" alt="" src="<%= avatar %>">
<span class="author">Toni Boehm</span>
</a>
<% } else { %>
<i class="fa fa-user"></i>
<% } %>'
)
assigneeTemplate = _.template(
'<% if (username) { %>
<a class="author_link " href="/u/<%= username %>">
<% if( avatar ) { %>
<img width="32" class="avatar avatar-inline s32" alt="" src="<%= avatar %>">
<% } %>
<span class="author"><%= name %></span>
<span class="username">
@<%= username %>
</span>
</a>
<% } else { %>
<span class="assign-yourself">
No assignee -
<a href="#" class="js-assign-yourself">
assign yourself
</a>
</span>
<% } %>'
)
$dropdown.glDropdown(
data: (term, callback) =>
......@@ -57,20 +134,38 @@ class @UsersSelect
fields: ['name', 'username']
selectable: true
fieldName: $dropdown.data('field-name')
toggleLabel: (selected) ->
if selected && 'id' of selected
selected.name
else
defaultLabel
clicked: ->
inputId: 'issue_assignee_id'
hidden: (e) ->
$selectbox.hide()
# display:block overrides the hide-collapse rule
$value.removeAttr('style')
clicked: (user) ->
page = $('body').data 'page'
isIssueIndex = page is 'projects:issues:index'
isMRIndex = page is page is 'projects:merge_requests:index'
if $dropdown.hasClass('js-filter-bulk-update')
return
if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex)
selectedId = user.id
Issues.filterResults $dropdown.closest('form')
else if $dropdown.hasClass 'js-filter-submit'
$dropdown.closest('form').submit()
else
selected = $dropdown
.closest('.selectbox')
.find("input[name='#{$dropdown.data('field-name')}']").val()
assignTo(selected)
renderRow: (user) ->
username = if user.username then "@#{user.username}" else ""
avatar = if user.avatar_url then user.avatar_url else false
......@@ -87,17 +182,25 @@ class @UsersSelect
if avatar
img = "<img src='#{avatar}' class='avatar avatar-inline' width='30' />"
"<li>
<a href='#' class='dropdown-menu-user-link #{selected}'>
#{img}
<strong class='dropdown-menu-user-full-name'>
#{user.name}
</strong>
<span class='dropdown-menu-user-username'>
#{username}
</span>
</a>
</li>"
# split into three parts so we can remove the username section if nessesary
listWithName = "<li>
<a href='#' class='dropdown-menu-user-link #{selected}'>
#{img}
<strong class='dropdown-menu-user-full-name'>
#{user.name}
</strong>"
listWithUserName = "<span class='dropdown-menu-user-username'>
#{username}
</span>"
listClosingTags = "</a>
</li>"
if username is ''
listWithUserName = ''
listWithName + listWithUserName + listClosingTags
)
$('.ajax-users-select').each (i, select) =>
......
......@@ -10,6 +10,7 @@
*= require dropzone/basic
*= require cal-heatmap
*= require cropper.css
*= require animate
*/
/*
......
......@@ -136,6 +136,10 @@
background-color: $dropdown-empty-row-bg;
}
}
&.dropdown-menu-user-link {
line-height: 16px;
}
}
}
......@@ -167,13 +171,13 @@
}
.dropdown-menu-user-link {
padding-top: 7px;
padding-top: 10px;
padding-bottom: 7px;
}
.dropdown-menu-user-full-name {
display: block;
font-weight: 600;
font-weight: 500;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
......
......@@ -288,6 +288,10 @@
@media (min-width: $screen-sm-min) {
padding-right: $sidebar_collapsed_width;
}
.sidebar-collapsed-icon {
cursor: pointer;
}
}
.right-sidebar-expanded {
......@@ -300,4 +304,8 @@
@media (min-width: $screen-md-min) {
padding-right: $gutter_width;
}
&.with-overlay {
padding-right: $sidebar_collapsed_width;
}
}
......@@ -30,6 +30,10 @@
}
.issuable-sidebar {
a {
color: inherit;
}
.block {
@include clearfix;
padding: $gl-padding 0;
......@@ -89,7 +93,7 @@
}
.cross-project-reference {
color: $gl-link-color;
color: inherit;
span {
white-space: nowrap;
......@@ -133,6 +137,12 @@
.value {
line-height: 1;
.assign-yourself {
margin-top: 10px;
font-weight: normal;
display: block;
}
}
.bold {
......@@ -252,6 +262,15 @@
text-decoration: none;
}
}
.dropdown-menu-toggle {
width: 100%;
padding-top: 6px;
}
.open .dropdown-menu {
width: 100%;
}
}
.btn-default.gutter-toggle {
......
......@@ -230,3 +230,9 @@
}
}
}
.builds {
.table-holder {
overflow-x: scroll;
}
}
......@@ -71,8 +71,6 @@
}
.note-form-actions {
background: #fff;
.note-form-option {
margin-top: 8px;
margin-left: 30px;
......
......@@ -162,7 +162,7 @@
margin-right: 12px;
a {
margin: -1px !important;
margin: -1px;
}
}
......
......@@ -13,6 +13,12 @@
}
}
.todo {
&:hover {
cursor: pointer;
}
}
.todo-item {
.todo-title {
@include str-truncated(calc(100% - 174px));
......
module Ci
class ProjectsController < Ci::ApplicationController
before_action :project
before_action :authorize_read_project!, except: [:badge]
before_action :no_cache, only: [:badge]
before_action :authorize_read_project!, except: [:badge, :index]
skip_before_action :authenticate_user!, only: [:badge]
protect_from_forgery
def index
redirect_to root_path
end
def show
# Temporary compatibility with CI badges pointing to CI project page
redirect_to namespace_project_path(project.namespace, project)
......@@ -35,5 +39,9 @@ module Ci
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
end
def authorize_read_project!
return access_denied! unless can?(current_user, :read_project, project)
end
end
end
......@@ -2,11 +2,12 @@ class Projects::BadgesController < Projects::ApplicationController
before_action :no_cache_headers
def build
badge = Gitlab::Badge::Build.new(project, params[:ref])
respond_to do |format|
format.html { render_404 }
format.svg do
image = Ci::ImageForBuildService.new.execute(project, ref: params[:ref])
send_file(image.path, filename: image.name, disposition: 'inline', type: 'image/svg+xml')
send_data(badge.data, type: badge.type, disposition: 'inline')
end
end
end
......
......@@ -68,7 +68,13 @@ class Projects::IssuesController < Projects::ApplicationController
@merge_requests = @issue.referenced_merge_requests(current_user)
@related_branches = @issue.related_branches - @merge_requests.map(&:source_branch)
respond_with(@issue)
respond_to do |format|
format.html
format.json do
render json: @issue.to_json(include: [:milestone, :labels])
end
end
end
def create
......@@ -107,10 +113,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
format.json do
render json: {
saved: @issue.valid?,
assignee_avatar_url: @issue.assignee.try(:avatar_url)
}
render json: @issue.to_json(include: [:milestone, :labels, assignee: { methods: :avatar_url }])
end
end
end
......
......@@ -57,8 +57,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json { render json: @merge_request }
format.diff { render text: @merge_request.to_diff(current_user) }
format.patch { render text: @merge_request.to_patch(current_user) }
format.diff { render text: @merge_request.to_diff }
format.patch { render text: @merge_request.to_patch }
end
end
......@@ -154,10 +154,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@merge_request.target_project, @merge_request])
end
format.json do
render json: {
saved: @merge_request.valid?,
assignee_avatar_url: @merge_request.assignee.try(:avatar_url)
}
render json: @merge_request.to_json(include: [:milestone, :labels, assignee: { methods: :avatar_url }])
end
end
else
......
......@@ -19,13 +19,12 @@ class Projects::MilestonesController < Projects::ApplicationController
end
@milestones = @milestones.includes(:project)
respond_to do |format|
format.html do
@milestones = @milestones.page(params[:page])
end
format.json do
render json: @milestones
render json: @milestones.to_json(methods: :name)
end
end
end
......
......@@ -3,7 +3,7 @@ class Projects::SnippetsController < Projects::ApplicationController
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
# Allow read any snippet
before_action :authorize_read_project_snippet!
before_action :authorize_read_project_snippet!, except: [:new, :create, :index]
# Allow write(create) snippet
before_action :authorize_create_project_snippet!, only: [:new, :create]
......@@ -81,6 +81,10 @@ class Projects::SnippetsController < Projects::ApplicationController
@snippet ||= @project.snippets.find(params[:id])
end
def authorize_read_project_snippet!
return render_404 unless can?(current_user, :read_project_snippet, @snippet)
end
def authorize_update_project_snippet!
return render_404 unless can?(current_user, :update_project_snippet, @snippet)
end
......
......@@ -138,7 +138,7 @@ class ProjectsController < Projects::ApplicationController
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id)
@suggestions = {
emojis: autocomplete_emojis,
emojis: AwardEmoji.urls,
issues: autocomplete.issues,
mergerequests: autocomplete.merge_requests,
members: participants
......@@ -235,17 +235,6 @@ class ProjectsController < Projects::ApplicationController
)
end
def autocomplete_emojis
Rails.cache.fetch("autocomplete-emoji-#{Gemojione::VERSION}") do
Emoji.emojis.map do |name, emoji|
{
name: name,
path: view_context.image_url("#{emoji["unicode"]}.png")
}
end
end
end
def repo_exists?
project.repository_exists? && !project.empty_repo?
end
......
......@@ -243,7 +243,7 @@ class IssuableFinder
end
def filter_by_upcoming_milestone?
params[:milestone_title] == '#upcoming'
params[:milestone_title] == Milestone::Upcoming.name
end
def by_milestone(items)
......@@ -252,7 +252,7 @@ class IssuableFinder
items = items.where(milestone_id: [-1, nil])
elsif filter_by_upcoming_milestone?
upcoming = Milestone.where(project_id: projects).upcoming
items = items.joins(:milestone).where(milestones: { title: upcoming.title })
items = items.joins(:milestone).where(milestones: { title: upcoming.try(:title) })
else
items = items.joins(:milestone).where(milestones: { title: params[:milestone_title] })
......
......@@ -60,7 +60,7 @@ module DropdownsHelper
title_output << content_tag(:span, title)
title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do
icon('times')
icon('times', class: 'dropdown-menu-close-icon')
end
title_output.html_safe
......
......@@ -16,6 +16,16 @@ module IssuablesHelper
base_issuable_scope(issuable).where('iid > ?', issuable.iid).last
end
def issuable_json_path(issuable)
project = issuable.project
if issuable.kind_of?(MergeRequest)
namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json)
else
namespace_project_issue_path(project.namespace, project, issuable.iid, :json)
end
end
def prev_issuable_for(issuable)
base_issuable_scope(issuable).where('iid < ?', issuable.iid).first
end
......@@ -37,6 +47,14 @@ module IssuablesHelper
end
end
def milestone_dropdown_label(milestone_title, default_label = "Milestone")
if milestone_title == Milestone::Upcoming.name
milestone_title = Milestone::Upcoming.title
end
h(milestone_title.presence || default_label)
end
private
def sidebar_gutter_collapsed?
......
......@@ -5,8 +5,10 @@ module NotesHelper
end
def note_target_fields(note)
hidden_field_tag(:target_type, note.noteable.class.name.underscore) +
hidden_field_tag(:target_id, note.noteable.id)
if note.noteable
hidden_field_tag(:target_type, note.noteable.class.name.underscore) +
hidden_field_tag(:target_id, note.noteable.id)
end
end
def note_editable?(note)
......
......@@ -110,6 +110,10 @@ class Notify < BaseMailer
headers['Reply-To'] = address
fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze
headers['References'] ||= ''
headers['References'] << ' ' << fallback_reply_message_id
@reply_by_email = true
end
......
......@@ -27,6 +27,8 @@ class Ability
case true
when subject.is_a?(PersonalSnippet)
anonymous_personal_snippet_abilities(subject)
when subject.is_a?(ProjectSnippet)
anonymous_project_snippet_abilities(subject)
when subject.is_a?(CommitStatus)
anonymous_commit_status_abilities(subject)
when subject.is_a?(Project) || subject.respond_to?(:project)
......@@ -100,6 +102,14 @@ class Ability
end
end
def anonymous_project_snippet_abilities(snippet)
if snippet.public?
[:read_project_snippet]
else
[]
end
end
def global_abilities(user)
rules = []
rules << :create_group if user.can_create_group
......@@ -338,24 +348,22 @@ class Ability
end
end
[:note, :project_snippet].each do |name|
define_method "#{name}_abilities" do |user, subject|
rules = []
if subject.author == user
rules += [
:"read_#{name}",
:"update_#{name}",
:"admin_#{name}"
]
end
def note_abilities(user, note)
rules = []
if subject.respond_to?(:project) && subject.project
rules += project_abilities(user, subject.project)
end
if note.author == user
rules += [
:read_note,
:update_note,
:admin_note
]
end
rules
if note.respond_to?(:project) && note.project
rules += project_abilities(user, note.project)
end
rules
end
def personal_snippet_abilities(user, snippet)
......@@ -376,6 +384,24 @@ class Ability
rules
end
def project_snippet_abilities(user, snippet)
rules = []
if snippet.author == user || user.admin?
rules += [
:read_project_snippet,
:update_project_snippet,
:admin_project_snippet
]
end
if snippet.public? || (snippet.internal? && !user.external?) || (snippet.private? && snippet.project.team.member?(user))
rules << :read_project_snippet
end
rules
end
def group_member_abilities(user, subject)
rules = []
target_user = subject.user
......
......@@ -14,6 +14,7 @@ class GlobalMilestone
def initialize(title, milestones)
@title = title
@name = title
@milestones = milestones
end
......
......@@ -279,7 +279,7 @@ class MergeRequest < ActiveRecord::Base
WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze
def work_in_progress?
title =~ WIP_REGEX
!!(title =~ WIP_REGEX)
end
def wipless_title
......@@ -331,15 +331,15 @@ class MergeRequest < ActiveRecord::Base
# Returns the raw diff for this merge request
#
# see "git diff"
def to_diff(current_user)
target_project.repository.diff_text(target_branch, source_sha)
def to_diff
target_project.repository.diff_text(diff_base_commit.sha, source_sha)
end
# Returns the commit as a series of email patches.
#
# see "git format-patch"
def to_patch(current_user)
target_project.repository.format_patch(target_branch, source_sha)
def to_patch
target_project.repository.format_patch(diff_base_commit.sha, source_sha)
end
def hook_attrs
......
......@@ -22,7 +22,7 @@ class SlackService
@issue_url = obj_attr[:url]
@action = obj_attr[:action]
@state = obj_attr[:state]
@description = obj_attr[:description]
@description = obj_attr[:description] || ''
end
def attachments
......
.wiki
%h1
GitLab CI is now integrated in GitLab UI
%h2 For existing projects
%p
Check the following pages to find the CI status you're looking for:
%ul
%li Projects page - shows CI status for each project.
%li Project commits page - show CI status for each commit.
%h2 For new projects
%p
If you want to enable CI for a new project it is easy as adding
= link_to ".gitlab-ci.yml", "http://doc.gitlab.com/ce/ci/yaml/README.html"
file to your repository
%li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) }
%li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data:{url: todo_target_path(todo)} }
.todo-item.todo-block
= image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:''
......
......@@ -56,19 +56,20 @@
.prepend-top-default
= f.submit 'Update settings', class: "btn btn-create"
%hr
%h5
Groups (#{@group_members.count})
%div
%ul.bordered-list
- @group_members.each do |group_member|
- notification = Notification.new(group_member)
= render 'settings', type: 'group', membership: group_member, notification: notification
%h5
Projects (#{@project_members.count})
%p.account-well
To specify the notification level per project of a group you belong to, you need to be a member of the project itself, not only its group.
.append-bottom-default
%ul.bordered-list
- @project_members.each do |project_member|
- notification = Notification.new(project_member)
= render 'settings', type: 'project', membership: project_member, notification: notification
.col-lg-9.col-lg-push-3
%h5
Groups (#{@group_members.count})
%div
%ul.bordered-list
- @group_members.each do |group_member|
- notification = Notification.new(group_member)
= render 'settings', type: 'group', membership: group_member, notification: notification
%h5
Projects (#{@project_members.count})
%p.account-well
To specify the notification level per project of a group you belong to, you need to be a member of the project itself, not only its group.
.append-bottom-default
%ul.bordered-list
- @project_members.each do |project_member|
- notification = Notification.new(project_member)
= render 'settings', type: 'project', membership: project_member, notification: notification
......@@ -12,7 +12,7 @@
= link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork project", class: 'btn has-tooltip' do
= icon('code-fork fw')
Fork
%div.count-with-arrow
= link_to namespace_project_forks_path(@project.namespace, @project), class: 'count-with-arrow' do
%span.arrow
%span.count
= @project.forks_count
......@@ -10,7 +10,7 @@
.merge-request{'data-url' => merge_request_path(@merge_request)}
= render "projects/merge_requests/show/mr_title"
.merge-request-details.issuable-details
.merge-request-details.issuable-details{data: {id: @merge_request.project.id}}
= render "projects/merge_requests/show/mr_box"
.append-bottom-default.mr-source-target.prepend-top-default
- if @merge_request.open?
......
- note = discussion_notes.first
- commit = note.noteable
- commit_description = commit ? 'commit' : 'a deleted commit'
.discussion.js-toggle-container{ class: note.discussion_id }
.discussion-header
.discussion-actions
......@@ -7,8 +9,9 @@
Show/hide discussion
%div
= link_to_member(@project, note.author, avatar: false)
started a discussion on commit
= link_to(note.noteable.short_id, namespace_project_commit_path(note.project.namespace, note.project, note.noteable), class: 'monospace')
%p started a discussion on #{commit_description}
- if commit
= link_to(commit.short_id, namespace_project_commit_path(note.project.namespace, note.project, note.noteable), class: 'monospace')
.last-update.hide.js-toggle-content
- last_note = discussion_notes.last
last updated by
......
......@@ -9,13 +9,13 @@
.filter-item.inline
- if params[:author_id]
= hidden_field_tag(:author_id, params[:author_id])
= dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author",
= dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit",
placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id", default_label: "Author" } })
.filter-item.inline
- if params[:assignee_id]
= hidden_field_tag(:assignee_id, params[:assignee_id])
= dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee",
= dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
.filter-item.inline.milestone-filter
......@@ -23,7 +23,6 @@
.filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown"
.pull-right
= render 'shared/sort_dropdown'
......@@ -38,11 +37,10 @@
%li
%a{href: "#", data: {id: "close"}} Closed
.filter-item.inline
= dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
= dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } })
.filter-item.inline
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone",
placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
= hidden_field_tag 'update[issues_ids]', []
= hidden_field_tag :state_event, params[:state_event]
.filter-item.inline
......
......@@ -14,7 +14,7 @@
- if issuable.is_a?(MergeRequest)
%p.help-block
.js-wip-explanation
%a.js-toggle-wip{href: ""}
%a.js-toggle-wip{href: "", tabindex: -1}
Remove the
%code WIP:
prefix from the title
......@@ -22,7 +22,7 @@
%strong Work In Progress
merge request to be merged when it's ready.
.js-no-wip-explanation
%a.js-toggle-wip{href: ""}
%a.js-toggle-wip{href: "", tabindex: -1}
Start the title with
%code WIP:
to prevent a
......
- if params[:milestone_title]
= hidden_field_tag(:milestone_title, params[:milestone_title])
= dropdown_tag(h(params[:milestone_title].presence || "Milestone"), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable",
placeholder: "Search milestones", footer_content: @project.present?, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: @project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
= dropdown_tag(milestone_dropdown_label(params[:milestone_title]), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable",
placeholder: "Search milestones", footer_content: @project.present?, data: { show_no: true, show_any: true, show_upcoming: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: @project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
- if @project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, @project
......
......@@ -28,6 +28,7 @@
= icon('user')
.title.hide-collapsed
Assignee
= icon('spinner spin', class: 'block-loading')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.bold.hide-collapsed
......@@ -39,10 +40,14 @@
%span.username
= issuable.assignee.to_reference
- else
.light None
%span.assign-yourself
No assignee -
%a.js-assign-yourself{ href: '#' }
assign yourself
.selectbox.hide-collapsed
= users_select_tag("#{issuable.class.table_name.singularize}[assignee_id]", placeholder: 'Select assignee', class: 'custom-form-control js-select2 js-assignee', selected: issuable.assignee_id, project: @target_project, null_user: true, current_user: true, first_user: true)
= f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id'
= dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } })
.block.milestone
.sidebar-collapsed-icon
......@@ -54,6 +59,7 @@
No
.title.hide-collapsed
Milestone
= icon('spinner spin', class: 'block-loading')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.bold.hide-collapsed
......@@ -62,10 +68,10 @@
= issuable.milestone.title
- else
.light None
.selectbox.hide-collapsed
= f.select(:milestone_id, milestone_options(issuable), { include_blank: true }, { class: 'select2 select2-compact js-select2 js-milestone', data: { placeholder: 'Select milestone' }})
= hidden_field_tag :issuable_context
= f.submit class: 'btn hide'
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
- if issuable.project.labels.any?
.block.labels
......@@ -75,6 +81,7 @@
= issuable.labels.count
.title.hide-collapsed
Labels
= icon('spinner spin', class: 'block-loading')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.bold.issuable-show-labels.hide-collapsed{ class: ("has-labels" if issuable.labels.any?) }
......@@ -84,8 +91,31 @@
- else
.light None
.selectbox.hide-collapsed
= f.collection_select :label_ids, issuable.project.labels.all, :id, :name,
{ selected: issuable.label_ids }, multiple: true, class: 'select2 js-select2', data: { placeholder: "Select labels" }
- issuable.labels.each do |label|
= hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect{type: "button", data: {toggle: "dropdown", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", project_id: (@project.id if @project), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project)}}
%span.dropdown-toggle-text
Label
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
.dropdown-page-one
= dropdown_title("Assign labels")
= dropdown_filter("Search labels")
= dropdown_content
- if @project
= dropdown_footer do
%ul.dropdown-footer-list
- if can? current_user, :admin_label, @project
%li
%a.dropdown-toggle-page{href: "#"}
Create new
%li
= link_to namespace_project_labels_path(@project.namespace, @project) do
- if can? current_user, :admin_label, @project
Manage labels
- else
View labels
= render "shared/issuable/participants", participants: issuable.participants(current_user)
- if current_user
......@@ -116,5 +146,8 @@
= clipboard_button(clipboard_text: project_ref)
:javascript
new Subscription('.subscription');
new IssuableContext();
new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect();
new IssuableContext('#{current_user.to_json(only: [:username, :id, :name])}');
new Subscription('.subscription')
new Sidebar();
\ No newline at end of file
......@@ -106,7 +106,7 @@ production: &base
enabled: false
# The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
# The `%{key}` placeholder is added after the user part, after a `+` character, before the `@`.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
address: "gitlab-incoming+%{key}@gmail.com"
# Email account username
......
......@@ -17,7 +17,7 @@ if File.exists?(config_file)
config['start_tls'] = false if config['start_tls'].nil?
config['mailbox'] = "inbox" if config['mailbox'].nil?
if config['enabled'] && config['address'] && config['address'].include?('%{key}')
if config['enabled'] && config['address']
redis_url = Gitlab::RedisConfig.new(rails_env).url
%>
-
......
class ConvertClosedToStateInIssue < ActiveRecord::Migration
include Gitlab::Database
def up
Issue.transaction do
Issue.where(closed: true).update_all(state: :closed)
Issue.where(closed: false).update_all(state: :opened)
end
execute "UPDATE #{table_name} SET state = 'closed' WHERE closed = #{true_value}"
execute "UPDATE #{table_name} SET state = 'opened' WHERE closed = #{false_value}"
end
def down
Issue.transaction do
Issue.where(state: :closed).update_all(closed: true)
end
execute "UPDATE #{table_name} SET closed = #{true_value} WHERE state = 'closed'"
end
private
def table_name
Issue.table_name
end
end
class ConvertClosedToStateInMergeRequest < ActiveRecord::Migration
include Gitlab::Database
def up
MergeRequest.transaction do
MergeRequest.where(closed: true, merged: true).update_all(state: :merged)
MergeRequest.where(closed: true, merged: false).update_all(state: :closed)
MergeRequest.where(closed: false).update_all(state: :opened)
end
execute "UPDATE #{table_name} SET state = 'merged' WHERE closed = #{true_value} AND merged = #{true_value}"
execute "UPDATE #{table_name} SET state = 'closed' WHERE closed = #{true_value} AND merged = #{false_value}"
execute "UPDATE #{table_name} SET state = 'opened' WHERE closed = #{false_value}"
end
def down
MergeRequest.transaction do
MergeRequest.where(state: :closed).update_all(closed: true)
MergeRequest.where(state: :merged).update_all(closed: true, merged: true)
end
execute "UPDATE #{table_name} SET closed = #{true_value} WHERE state = 'closed'"
execute "UPDATE #{table_name} SET closed = #{true_value}, merged = #{true_value} WHERE state = 'merged'"
end
private
def table_name
MergeRequest.table_name
end
end
class ConvertClosedToStateInMilestone < ActiveRecord::Migration
include Gitlab::Database
def up
Milestone.transaction do
Milestone.where(closed: true).update_all(state: :closed)
Milestone.where(closed: false).update_all(state: :active)
end
execute "UPDATE #{table_name} SET state = 'closed' WHERE closed = #{true_value}"
execute "UPDATE #{table_name} SET state = 'active' WHERE closed = #{false_value}"
end
def down
Milestone.transaction do
Milestone.where(state: :closed).update_all(closed: true)
end
execute "UPDATE #{table_name} SET closed = #{true_value} WHERE state = 'cloesd'"
end
private
def table_name
Milestone.table_name
end
end
class ConvertMergeStatusInMergeRequest < ActiveRecord::Migration
def up
MergeRequest.transaction do
MergeRequest.where(merge_status: 1).update_all("new_merge_status = 'unchecked'")
MergeRequest.where(merge_status: 2).update_all("new_merge_status = 'can_be_merged'")
MergeRequest.where(merge_status: 3).update_all("new_merge_status = 'cannot_be_merged'")
end
execute "UPDATE #{table_name} SET new_merge_status = 'unchecked' WHERE merge_status = 1"
execute "UPDATE #{table_name} SET new_merge_status = 'can_be_merged' WHERE merge_status = 2"
execute "UPDATE #{table_name} SET new_merge_status = 'cannot_be_merged' WHERE merge_status = 3"
end
def down
MergeRequest.transaction do
MergeRequest.where(new_merge_status: :unchecked).update_all("merge_status = 1")
MergeRequest.where(new_merge_status: :can_be_merged).update_all("merge_status = 2")
MergeRequest.where(new_merge_status: :cannot_be_merged).update_all("merge_status = 3")
end
execute "UPDATE #{table_name} SET merge_status = 1 WHERE new_merge_status = 'unchecked'"
execute "UPDATE #{table_name} SET merge_status = 2 WHERE new_merge_status = 'can_be_merged'"
execute "UPDATE #{table_name} SET merge_status = 3 WHERE new_merge_status = 'cannot_be_merged'"
end
private
def table_name
MergeRequest.table_name
end
end
class AllowMergesForForks < ActiveRecord::Migration
def self.up
add_column :merge_requests, :target_project_id, :integer, :null => true
MergeRequest.update_all("target_project_id = project_id")
execute "UPDATE #{table_name} SET target_project_id = project_id"
change_column :merge_requests, :target_project_id, :integer, :null => false
rename_column :merge_requests, :project_id, :source_project_id
end
......@@ -10,4 +10,10 @@ class AllowMergesForForks < ActiveRecord::Migration
remove_column :merge_requests, :target_project_id
rename_column :merge_requests, :source_project_id,:project_id
end
private
def table_name
MergeRequest.table_name
end
end
......@@ -8,9 +8,9 @@ Get all labels for a given project.
GET /projects/:id/labels
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project |
| Attribute | Type | Required | Description |
| --------- | ------- | -------- | --------------------- |
| `id` | integer | yes | The ID of the project |
```bash
curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/labels
......@@ -22,35 +22,43 @@ Example response:
[
{
"name" : "bug",
"color" : "#d9534f"
"color" : "#d9534f",
"description": "Bug reported by user"
},
{
"color" : "#d9534f",
"name" : "confirmed"
"name" : "confirmed",
"description": "Confirmed issue"
},
{
"name" : "critical",
"color" : "#d9534f"
"color" : "#d9534f",
"description": "Criticalissue. Need fix ASAP"
},
{
"color" : "#428bca",
"name" : "discussion"
"name" : "discussion",
"description": "Issue that needs further discussion"
},
{
"name" : "documentation",
"color" : "#f0ad4e"
"color" : "#f0ad4e",
"description": "Issue about documentation"
},
{
"color" : "#5cb85c",
"name" : "enhancement"
"name" : "enhancement",
"description": "Enhancement proposal"
},
{
"color" : "#428bca",
"name" : "suggestion"
"name" : "suggestion",
"description": "Suggestion"
},
{
"color" : "#f0ad4e",
"name" : "support"
"name" : "support",
"description": "Support issue"
}
]
```
......@@ -66,11 +74,12 @@ and 409 if the label already exists.
POST /projects/:id/labels
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project |
| `name` | string | yes | The name of the label |
| `color` | string | yes | The color of the label in 6-digit hex notation with leading `#` sign |
| Attribute | Type | Required | Description |
| ------------- | ------- | -------- | ---------------------------- |
| `id` | integer | yes | The ID of the project |
| `name` | string | yes | The name of the label |
| `color` | string | yes | The color of the label in 6-digit hex notation with leading `#` sign |
| `description` | string | no | The description of the label |
```bash
curl --data "name=feature&color=#5843AD" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels"
......@@ -81,7 +90,8 @@ Example response:
```json
{
"name" : "feature",
"color" : "#5843AD"
"color" : "#5843AD",
"description":null
}
```
......@@ -97,10 +107,10 @@ In case of an error, an additional error message is returned.
DELETE /projects/:id/labels
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project |
| `name` | string | yes | The name of the label |
| Attribute | Type | Required | Description |
| --------- | ------- | -------- | --------------------- |
| `id` | integer | yes | The ID of the project |
| `name` | string | yes | The name of the label |
```bash
curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels?name=bug"
......@@ -112,6 +122,7 @@ Example response:
{
"title" : "feature",
"color" : "#5843AD",
"description": "New feature proposal",
"updated_at" : "2015-11-03T21:22:30.737Z",
"template" : false,
"project_id" : 1,
......@@ -133,15 +144,16 @@ In case of an error, an additional error message is returned.
PUT /projects/:id/labels
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project |
| `name` | string | yes | The name of the existing label |
| `new_name` | string | yes if `color` if not provided | The new name of the label |
| `color` | string | yes if `new_name` is not provided | The new color of the label in 6-digit hex notation with leading `#` sign |
| Attribute | Type | Required | Description |
| --------------- | ------- | --------------------------------- | ------------------------------- |
| `id` | integer | yes | The ID of the project |
| `name` | string | yes | The name of the existing label |
| `new_name` | string | yes if `color` if not provided | The new name of the label |
| `color` | string | yes if `new_name` is not provided | The new color of the label in 6-digit hex notation with leading `#` sign |
| `description` | string | no | The new description of the label |
```bash
curl -X PUT --data "name=documentation&new_name=docs&color=#8E44AD" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels"
curl -X PUT --data "name=documentation&new_name=docs&color=#8E44AD&description=Documentation" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels"
```
Example response:
......@@ -149,6 +161,7 @@ Example response:
```json
{
"color" : "#8E44AD",
"name" : "docs"
"name" : "docs",
"description": "Documentation"
}
```
# Reply by email
GitLab can be set up to allow users to comment on issues and merge requests by replying to notification emails.
GitLab can be set up to allow users to comment on issues and merge requests by
replying to notification emails.
## Get a mailbox
## Requirement
Reply by email requires an IMAP-enabled email account, with a provider or server that supports [email sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing). Sub-addressing is a feature where any email to `user+some_arbitrary_tag@example.com` will end up in the mailbox for `user@example.com`, and is supported by providers such as Gmail, Google Apps, Yahoo! Mail, Outlook.com and iCloud, as well as the Postfix mail server which you can run on-premises.
Reply by email requires an IMAP-enabled email account. GitLab allows you to use
three strategies for this feature:
- using email sub-addressing
- using a dedicated email address
- using a catch-all mailbox
If you want to use Gmail / Google Apps with Reply by email, make sure you have [IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018) and [allow less secure apps to access the account](https://support.google.com/accounts/answer/6010255).
### Email sub-addressing
To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these instructions](./postfix.md).
**If your provider or server supports email sub-addressing, we recommend using it.**
[Sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) is
a feature where any email to `user+some_arbitrary_tag@example.com` will end up
in the mailbox for `user@example.com`, and is supported by providers such as
Gmail, Google Apps, Yahoo! Mail, Outlook.com and iCloud, as well as the Postfix
mail server which you can run on-premises.
### Dedicated email address
This solution is really simple to set up: you just have to create an email
address dedicated to receive your users' replies to GitLab notifications.
### Catch-all mailbox
A [catch-all mailbox](https://en.wikipedia.org/wiki/Catch-all) for a domain will
"catch all" the emails addressed to the domain that do not exist in the mail
server.
## How it works?
### 1. GitLab sends a notification email
When GitLab sends a notification and Reply by email is enabled, the `Reply-To`
header is set to the address defined in your GitLab configuration, with the
`%{key}` placeholder (if present) replaced by a specific "reply key". In
addition, this "reply key" is also added to the `References` header.
### 2. You reply to the notification email
When you reply to the notification email, your email client will:
- send the email to the `Reply-To` address it got from the notification email
- set the `In-Reply-To` header to the value of the `Message-ID` header from the
notification email
- set the `References` header to the value of the `Message-ID` plus the value of
the notification email's `References` header.
### 3. GitLab receives your reply to the notification email
When GitLab receives your reply, it will look for the "reply key" in the
following headers, in this order:
1. the `To` header
1. the `References` header
If it finds a reply key, it will be able to leave your reply as a comment on
the entity the notification was about (issue, merge request, commit...).
For more details about the `Message-ID`, `In-Reply-To`, and `References headers`,
please consult [RFC 5322](https://tools.ietf.org/html/rfc5322#section-3.6.4).
## Set it up
If you want to use Gmail / Google Apps with Reply by email, make sure you have
[IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018)
and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255).
To set up a basic Postfix mail server with IMAP access on Ubuntu, follow
[these instructions](./postfix.md).
### Omnibus package installations
1. Find the `incoming_email` section in `/etc/gitlab/gitlab.rb`, enable the feature and fill in the details for your specific IMAP server and email account:
1. Find the `incoming_email` section in `/etc/gitlab/gitlab.rb`, enable the
feature and fill in the details for your specific IMAP server and email account:
```ruby
# Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com
gitlab_rails['incoming_email_enabled'] = true
# The email address including a placeholder for the key that references the item being replied to.
# The `%{key}` placeholder is added after the user part, before the `@`.
# The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
gitlab_rails['incoming_email_address'] = "incoming+%{key}@gitlab.example.com"
# Email account username
# With third party providers, this is usually the full email address.
# With self-hosted email servers, this is usually the user part of the email address.
gitlab_rails['incoming_email_email'] = "incoming"
# Email account password
gitlab_rails['incoming_email_password'] = "[REDACTED]"
# IMAP server host
gitlab_rails['incoming_email_host'] = "gitlab.example.com"
# IMAP server port
......@@ -47,18 +110,18 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
```ruby
# Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com
gitlab_rails['incoming_email_enabled'] = true
# The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
# The `%{key}` placeholder is added after the user part, after a `+` character, before the `@`.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
gitlab_rails['incoming_email_address'] = "gitlab-incoming+%{key}@gmail.com"
# Email account username
# With third party providers, this is usually the full email address.
# With self-hosted email servers, this is usually the user part of the email address.
gitlab_rails['incoming_email_email'] = "gitlab-incoming@gmail.com"
# Email account password
gitlab_rails['incoming_email_password'] = "[REDACTED]"
# IMAP server host
gitlab_rails['incoming_email_host'] = "imap.gmail.com"
# IMAP server port
......@@ -72,8 +135,6 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
gitlab_rails['incoming_email_mailbox_name'] = "inbox"
```
As mentioned, the part after `+` in the address is ignored, and any email sent here will end up in the mailbox for `incoming@gitlab.example.com`/`gitlab-incoming@gmail.com`.
1. Reconfigure GitLab and restart mailroom for the changes to take effect:
```sh
......@@ -97,7 +158,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
cd /home/git/gitlab
```
1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature and fill in the details for your specific IMAP server and email account:
1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature
and fill in the details for your specific IMAP server and email account:
```sh
sudo editor config/gitlab.yml
......@@ -109,7 +171,7 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
enabled: true
# The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
# The `%{key}` placeholder is added after the user part, after a `+` character, before the `@`.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
address: "incoming+%{key}@gitlab.example.com"
# Email account username
......@@ -138,7 +200,7 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
enabled: true
# The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
# The `%{key}` placeholder is added after the user part, after a `+` character, before the `@`.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
address: "gitlab-incoming+%{key}@gmail.com"
# Email account username
......@@ -161,8 +223,6 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
mailbox: "inbox"
```
As mentioned, the part after `+` in the address is ignored, and any email sent here will end up in the mailbox for `incoming@gitlab.example.com`/`gitlab-incoming@gmail.com`.
1. Enable `mail_room` in the init script at `/etc/default/gitlab`:
```sh
......@@ -195,8 +255,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow [these
incoming_email:
enabled: true
# The email address including a placeholder for the key that references the item being replied to.
# The `%{key}` placeholder is added after the user part, before the `@`.
# The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
address: "gitlab-incoming+%{key}@gmail.com"
# Email account username
......
......@@ -17,7 +17,7 @@ GitHub will generate an application ID and secret key for you to use.
- Application name: This can be anything. Consider something like "\<Organization\>'s GitLab" or "\<Your Name\>'s GitLab" or something else descriptive.
- Homepage URL: The URL to your GitLab installation. 'https://gitlab.company.com'
- Application description: Fill this in if you wish.
- Authorization callback URL: 'https://gitlab.company.com/'
- Default authorization callback URL is '${YOUR_DOMAIN}/import/github/callback'
1. Select "Register application".
1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot).
......
......@@ -62,7 +62,26 @@ sudo -u git -H git checkout v0.7.1
sudo -u git -H make
```
### 6. Install libs, migrations, etc.
### 6. Updates for PostgreSQL Users
Starting with 8.6 users using GitLab in combination with PostgreSQL are required
to have the `pg_trgm` extension enabled for all GitLab databases. If you're
using GitLab's Omnibus packages there's nothing you'll need to do manually as
this extension is enabled automatically. Users who install GitLab without using
Omnibus (e.g. by building from source) have to enable this extension manually.
To enable this extension run the following SQL command as a PostgreSQL super
user for _every_ GitLab database:
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```
Certain operating systems might require the installation of extra packages for
this extension to be available. For example, users using Ubuntu will have to
install the `postgresql-contrib` package in order for this extension to be
available.
### 7. Install libs, migrations, etc.
```bash
cd /home/git/gitlab
......@@ -84,7 +103,7 @@ sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS
```
### 7. Update configuration files
### 8. Update configuration files
#### New configuration options for `gitlab.yml`
......@@ -120,25 +139,6 @@ Ensure you're still up-to-date with the latest init script changes:
sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
### 8. Updates for PostgreSQL Users
Starting with 8.6 users using GitLab in combination with PostgreSQL are required
to have the `pg_trgm` extension enabled for all GitLab databases. If you're
using GitLab's Omnibus packages there's nothing you'll need to do manually as
this extension is enabled automatically. Users who install GitLab without using
Omnibus (e.g. by building from source) have to enable this extension manually.
To enable this extension run the following SQL command as a PostgreSQL super
user for _every_ GitLab database:
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```
Certain operating systems might require the installation of extra packages for
this extension to be available. For example, users using Ubuntu will have to
install the `postgresql-contrib` package in order for this extension to be
available.
### 9. Start application
sudo service gitlab start
......
......@@ -36,3 +36,8 @@ Feature: Dashboard Todos
Scenario: I filter by action
Given I filter by "Mentioned"
Then I should not see todos related to "Assignments" in the list
@javascript
Scenario: I click on a todo row
Given I click on the todo
Then I should be directed to the corresponding page
......@@ -88,6 +88,14 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps
should_not_see_todo "John Doe assigned you issue ##{issue.iid}"
end
step 'I click on the todo' do
find('.todo:nth-child(1)').click
end
step 'I should be directed to the corresponding page' do
page.should have_css('.identifier', text: 'Merge Request !1')
end
def should_see_todo(position, title, body, pending = true)
page.within(".todo:nth-child(#{position})") do
expect(page).to have_content title
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -292,7 +292,7 @@ module API
end
class Label < Grape::Entity
expose :name, :color
expose :name, :color, :description
end
class Compare < Grape::Entity
......
......@@ -17,17 +17,18 @@ module API
# Creates a new label
#
# Parameters:
# id (required) - The ID of a project
# name (required) - The name of the label to be deleted
# color (required) - Color of the label given in 6-digit hex
# notation with leading '#' sign (e.g. #FFAABB)
# id (required) - The ID of a project
# name (required) - The name of the label to be created
# color (required) - Color of the label given in 6-digit hex
# notation with leading '#' sign (e.g. #FFAABB)
# description (optional) - The description of label to be created
# Example Request:
# POST /projects/:id/labels
post ':id/labels' do
authorize! :admin_label, user_project
required_attributes! [:name, :color]
attrs = attributes_for_keys [:name, :color]
attrs = attributes_for_keys [:name, :color, :description]
label = user_project.find_label(attrs[:name])
conflict!('Label already exists') if label
......@@ -62,11 +63,12 @@ module API
# Updates an existing label. At least one optional parameter is required.
#
# Parameters:
# id (required) - The ID of a project
# name (required) - The name of the label to be deleted
# new_name (optional) - The new name of the label
# color (optional) - Color of the label given in 6-digit hex
# notation with leading '#' sign (e.g. #FFAABB)
# id (required) - The ID of a project
# name (required) - The name of the label to be deleted
# new_name (optional) - The new name of the label
# color (optional) - Color of the label given in 6-digit hex
# notation with leading '#' sign (e.g. #FFAABB)
# description (optional) - The description of label to be created
# Example Request:
# PUT /projects/:id/labels
put ':id/labels' do
......@@ -76,7 +78,7 @@ module API
label = user_project.find_label(params[:name])
not_found!('Label not found') unless label
attrs = attributes_for_keys [:new_name, :color]
attrs = attributes_for_keys [:new_name, :color, :description]
if attrs.empty?
render_api_error!('Required parameters "new_name" or "color" ' \
......
......@@ -48,4 +48,23 @@ class AwardEmoji
JSON.parse(File.read(json_path))
end
end
# Returns an Array of Emoji names and their asset URLs.
def self.urls
@urls ||= begin
path = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json')
prefix = Gitlab::Application.config.assets.prefix
digest = Gitlab::Application.config.assets.digest
JSON.parse(File.read(path)).map do |hash|
if digest
fname = "#{hash['unicode']}-#{hash['digest']}"
else
fname = hash['unicode']
end
{ name: hash['name'], path: "#{prefix}/#{fname}.png" }
end
end
end
end
require 'active_support/core_ext/string/output_safety'
module Banzai
module Filter
def self.[](name)
......
require 'html/pipeline/filter'
require 'uri'
module Banzai
......
require 'action_controller'
require 'gitlab_emoji'
require 'html/pipeline/filter'
module Banzai
module Filter
# HTML filter that replaces :emoji: with images.
......
require 'html/pipeline/filter'
module Banzai
module Filter
# HTML Filter to add a `rel="nofollow"` attribute to external links
......
require 'banzai'
require 'html/pipeline/filter'
module Banzai
module Filter
# HTML Filter for parsing Gollum's tags in HTML. It's only parses the
......
require 'html/pipeline/filter'
module Banzai
module Filter
class MarkdownFilter < HTML::Pipeline::TextFilter
......
require 'banzai'
module Banzai
module Filter
# HTML filter that replaces milestone references with links.
......
require 'html/pipeline/filter'
module Banzai
module Filter
# HTML filter that removes references to records that the current user does
......
require 'active_support/core_ext/string/output_safety'
require 'html/pipeline/filter'
module Banzai
module Filter
# Base class for GitLab Flavored Markdown reference filters.
......
require 'html/pipeline/filter'
module Banzai
module Filter
# HTML filter that gathers all referenced records that the current user has
......
require 'html/pipeline/filter'
require 'uri'
module Banzai
......
require 'html/pipeline/filter'
require 'html/pipeline/sanitization_filter'
module Banzai
module Filter
# Sanitize HTML
......
require 'html/pipeline/filter'
require 'rouge/plugins/redcarpet'
module Banzai
......
require 'html/pipeline/filter'
module Banzai
module Filter
# HTML filter that adds an anchor child element to all Headers in a
......
require 'html/pipeline/filter'
require 'uri'
module Banzai
......
require 'html/pipeline/filter'
require 'yaml'
module Banzai
module Filter
class YamlFrontMatterFilter < HTML::Pipeline::Filter
......
require 'html/pipeline'
module Banzai
module Pipeline
class BasePipeline
......
require 'banzai'
module Banzai
module Pipeline
class WikiPipeline < FullPipeline
......
module Gitlab
module Badge
##
# Build badge
#
class Build
def initialize(project, ref)
@image = ::Ci::ImageForBuildService.new.execute(project, ref: ref)
end
def to_s
@image[:name].sub(/\.svg$/, '')
end
def type
'image/svg+xml'
end
def data
File.read(@image[:path])
end
end
end
end
......@@ -63,6 +63,10 @@ module Gitlab
end
def reply_key
key_from_to_header || key_from_additional_headers
end
def key_from_to_header
key = nil
message.to.each do |address|
key = Gitlab::IncomingEmail.key_from_address(address)
......@@ -72,6 +76,17 @@ module Gitlab
key
end
def key_from_additional_headers
reply_key = nil
Array(message.references).each do |message_id|
reply_key = Gitlab::IncomingEmail.key_from_fallback_reply_message_id(message_id)
break if reply_key
end
reply_key
end
def sent_notification
return nil unless reply_key
......
module Gitlab
module IncomingEmail
class << self
def enabled?
config.enabled && address_formatted_correctly?
end
FALLBACK_REPLY_MESSAGE_ID_REGEX = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\Z/.freeze
def address_formatted_correctly?
config.address &&
config.address.include?("%{key}")
def enabled?
config.enabled && config.address
end
def reply_address(key)
......@@ -24,6 +21,13 @@ module Gitlab
match[1]
end
def key_from_fallback_reply_message_id(message_id)
match = message_id.match(FALLBACK_REPLY_MESSAGE_ID_REGEX)
return unless match
match[1]
end
def config
Gitlab.config.incoming_email
end
......
# This task will generate a standard and Retina sprite of all of the current
# Gemojione Emojis, with the accompanying SCSS map.
#
# It will not appear in `rake -T` output, and the dependent gems are not
# included in the Gemfile by default, because this task will only be needed
# occasionally, such as when new Emojis are added to Gemojione.
begin
require 'sprite_factory'
require 'rmagick'
rescue LoadError
# noop
end
namespace :gemojione do
desc 'Generates Emoji SHA256 digests'
task digests: :environment do
require 'digest/sha2'
require 'json'
dir = Gemojione.index.images_path
digests = AwardEmoji.emojis.map do |name, emoji_hash|
fpath = File.join(dir, "#{emoji_hash['unicode']}.png")
digest = Digest::SHA256.file(fpath).hexdigest
{ name: name, unicode: emoji_hash['unicode'], digest: digest }
end
out = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json')
File.open(out, 'w') do |handle|
handle.write(JSON.pretty_generate(digests))
end
end
# This task will generate a standard and Retina sprite of all of the current
# Gemojione Emojis, with the accompanying SCSS map.
#
# It will not appear in `rake -T` output, and the dependent gems are not
# included in the Gemfile by default, because this task will only be needed
# occasionally, such as when new Emojis are added to Gemojione.
task sprite: :environment do
begin
require 'sprite_factory'
require 'rmagick'
rescue LoadError
# noop
end
check_requirements!
SIZE = 20
......
......@@ -623,7 +623,6 @@ namespace :gitlab do
start_checking "Reply by email"
if Gitlab.config.incoming_email.enabled
check_address_formatted_correctly
check_imap_authentication
if Rails.env.production?
......@@ -643,20 +642,6 @@ namespace :gitlab do
# Checks
########################
def check_address_formatted_correctly
print "Address formatted correctly? ... "
if Gitlab::IncomingEmail.address_formatted_correctly?
puts "yes".green
else
puts "no".red
try_fixing_it(
"Make sure that the address in config/gitlab.yml includes the '%{key}' placeholder."
)
fix_and_rerun
end
end
def check_initd_configured_correctly
print "Init.d configured correctly? ... "
......
......@@ -5,6 +5,27 @@ describe Ci::ProjectsController do
let!(:project) { create(:project, visibility, ci_id: 1) }
let(:ci_id) { project.ci_id }
describe '#index' do
context 'user signed in' do
before do
sign_in(create(:user))
get(:index)
end
it 'redirects to /' do
expect(response).to redirect_to(root_path)
end
end
context 'user not signed in' do
before { get(:index) }
it 'redirects to sign in page' do
expect(response).to redirect_to(new_user_session_path)
end
end
end
##
# Specs for *deprecated* CI badge
#
......
......@@ -63,7 +63,7 @@ describe Projects::MergeRequestsController do
id: merge_request.iid,
format: format)
expect(response.body).to eq((merge_request.send(:"to_#{format}",user)).to_s)
expect(response.body).to eq((merge_request.send(:"to_#{format}")).to_s)
end
it "should not escape Html" do
......
require 'spec_helper'
describe Projects::SnippetsController do
let(:project) { create(:project_empty_repo, :public, snippets_enabled: true) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
before do
project.team << [user, :master]
project.team << [user2, :master]
end
describe 'GET #index' do
context 'when the project snippet is private' do
let!(:project_snippet) { create(:project_snippet, :private, project: project, author: user) }
context 'when anonymous' do
it 'does not include the private snippet' do
get :index, namespace_id: project.namespace.path, project_id: project.path
expect(assigns(:snippets)).not_to include(project_snippet)
expect(response.status).to eq(200)
end
end
context 'when signed in as the author' do
before { sign_in(user) }
it 'renders the snippet' do
get :index, namespace_id: project.namespace.path, project_id: project.path
expect(assigns(:snippets)).to include(project_snippet)
expect(response.status).to eq(200)
end
end
context 'when signed in as a project member' do
before { sign_in(user2) }
it 'renders the snippet' do
get :index, namespace_id: project.namespace.path, project_id: project.path
expect(assigns(:snippets)).to include(project_snippet)
expect(response.status).to eq(200)
end
end
end
end
%w[show raw].each do |action|
describe "GET ##{action}" do
context 'when the project snippet is private' do
let(:project_snippet) { create(:project_snippet, :private, project: project, author: user) }
context 'when anonymous' do
it 'responds with status 404' do
get action, namespace_id: project.namespace.path, project_id: project.path, id: project_snippet.to_param
expect(response.status).to eq(404)
end
end
context 'when signed in as the author' do
before { sign_in(user) }
it 'renders the snippet' do
get action, namespace_id: project.namespace.path, project_id: project.path, id: project_snippet.to_param
expect(assigns(:snippet)).to eq(project_snippet)
expect(response.status).to eq(200)
end
end
context 'when signed in as a project member' do
before { sign_in(user2) }
it 'renders the snippet' do
get action, namespace_id: project.namespace.path, project_id: project.path, id: project_snippet.to_param
expect(assigns(:snippet)).to eq(project_snippet)
expect(response.status).to eq(200)
end
end
end
context 'when the project snippet does not exist' do
context 'when anonymous' do
it 'responds with status 404' do
get action, namespace_id: project.namespace.path, project_id: project.path, id: 42
expect(response.status).to eq(404)
end
end
context 'when signed in' do
before { sign_in(user) }
it 'responds with status 404' do
get action, namespace_id: project.namespace.path, project_id: project.path, id: 42
expect(response.status).to eq(404)
end
end
end
end
end
end
......@@ -11,7 +11,41 @@ feature 'Issue filtering by Milestone', feature: true do
visit_issues(project)
filter_by_milestone(Milestone::None.title)
expect(page).to have_css('.issue .title', count: 1)
expect(page).to have_css('.issue', count: 1)
end
context 'filters by upcoming milestone', js: true do
it 'should not show issues with no expiry' do
create(:issue, project: project)
create(:issue, project: project, milestone: milestone)
visit_issues(project)
filter_by_milestone(Milestone::Upcoming.title)
expect(page).to have_css('.issue', count: 0)
end
it 'should show issues in future' do
milestone = create(:milestone, project: project, due_date: Date.tomorrow)
create(:issue, project: project)
create(:issue, project: project, milestone: milestone)
visit_issues(project)
filter_by_milestone(Milestone::Upcoming.title)
expect(page).to have_css('.issue', count: 1)
end
it 'should not show issues in past' do
milestone = create(:milestone, project: project, due_date: Date.yesterday)
create(:issue, project: project)
create(:issue, project: project, milestone: milestone)
visit_issues(project)
filter_by_milestone(Milestone::Upcoming.title)
expect(page).to have_css('.issue', count: 0)
end
end
scenario 'filters by a specific Milestone', js: true do
......@@ -21,7 +55,7 @@ feature 'Issue filtering by Milestone', feature: true do
visit_issues(project)
filter_by_milestone(milestone.title)
expect(page).to have_css('.issue .title', count: 1)
expect(page).to have_css('.issue', count: 1)
end
def visit_issues(project)
......@@ -30,8 +64,6 @@ feature 'Issue filtering by Milestone', feature: true do
def filter_by_milestone(title)
find(".js-milestone-select").click
sleep 0.5
find(".milestone-filter a", text: title).click
sleep 1
find(".milestone-filter .dropdown-content a", text: title).click
end
end
......@@ -34,20 +34,7 @@ describe 'Issues', feature: true do
fill_in 'issue_title', with: 'bug 345'
fill_in 'issue_description', with: 'bug description'
end
it 'does not change issue count' do
expect { click_button 'Save changes' }.to_not change { Issue.count }
end
it 'should update issue fields' do
click_button 'Save changes'
expect(page).to have_content @user.name
expect(page).to have_content 'bug 345'
expect(page).to have_content project.name
end
end
end
describe 'Editing issue assignee' do
......@@ -70,7 +57,7 @@ describe 'Issues', feature: true do
click_button 'Save changes'
page.within('.assignee') do
expect(page).to have_content 'None'
expect(page).to have_content 'No assignee - assign yourself'
end
expect(issue.reload.assignee).to be_nil
......@@ -198,20 +185,26 @@ describe 'Issues', feature: true do
end
describe 'update assignee from issue#show' do
let(:issue) { create(:issue, project: project, author: @user) }
let(:issue) { create(:issue, project: project, author: @user, assignee: @user) }
context 'by autorized user' do
it 'with dropdown menu' do
it 'allows user to select unassigned', js: true do
visit namespace_project_issue_path(project.namespace, project, issue)
find('.issuable-sidebar #issue_assignee_id').
set project.team.members.first.id
click_button 'Update Issue'
page.within('.assignee') do
expect(page).to have_content "#{@user.name}"
end
find('.block.assignee .edit-link').click
sleep 2 # wait for ajax stuff to complete
first('.dropdown-menu-user-link').click
sleep 2
page.within('.assignee') do
expect(page).to have_content 'No assignee'
end
expect(page).to have_content 'Assignee'
has_select?('issue_assignee_id',
selected: project.team.members.first.name)
expect(issue.reload.assignee).to be_nil
end
end
......@@ -221,8 +214,6 @@ describe 'Issues', feature: true do
before :each do
project.team << [[guest], :guest]
issue.assignee = @user
issue.save
end
it 'shows assignee text', js: true do
......@@ -241,20 +232,23 @@ describe 'Issues', feature: true do
context 'by authorized user' do
it 'with dropdown menu' do
visit namespace_project_issue_path(project.namespace, project, issue)
find('.issuable-sidebar').
select(milestone.title, from: 'issue_milestone_id')
click_button 'Update Issue'
it 'allows user to select unassigned', js: true do
visit namespace_project_issue_path(project.namespace, project, issue)
expect(page).to have_content "Milestone changed to #{milestone.title}"
page.within('.milestone') do
expect(page).to have_content "None"
end
find('.block.milestone .edit-link').click
sleep 2 # wait for ajax stuff to complete
first('.dropdown-content li').click
sleep 2
page.within('.milestone') do
expect(page).to have_content milestone.title
expect(page).to have_content 'None'
end
has_select?('issue_assignee_id', selected: milestone.title)
expect(issue.reload.milestone).to be_nil
end
end
......@@ -283,25 +277,6 @@ describe 'Issues', feature: true do
issue.assignee = user2
issue.save
end
it 'allows user to remove assignee', js: true do
visit namespace_project_issue_path(project.namespace, project, issue)
page.within('.assignee') do
expect(page).to have_content user2.name
end
find('.assignee .edit-link').click
sleep 2 # wait for ajax stuff to complete
first('.user-result').click
page.within('.assignee') do
expect(page).to have_content 'None'
end
sleep 2 # wait for ajax stuff to complete
expect(issue.reload.assignee).to be_nil
end
end
end
......
......@@ -11,7 +11,41 @@ feature 'Merge Request filtering by Milestone', feature: true do
visit_merge_requests(project)
filter_by_milestone(Milestone::None.title)
expect(page).to have_css('.merge-request-title', count: 1)
expect(page).to have_css('.merge-request', count: 1)
end
context 'filters by upcoming milestone', js: true do
it 'should not show issues with no expiry' do
create(:merge_request, :with_diffs, source_project: project)
create(:merge_request, :simple, source_project: project, milestone: milestone)
visit_merge_requests(project)
filter_by_milestone(Milestone::Upcoming.title)
expect(page).to have_css('.merge-request', count: 0)
end
it 'should show issues in future' do
milestone = create(:milestone, project: project, due_date: Date.tomorrow)
create(:merge_request, :with_diffs, source_project: project)
create(:merge_request, :simple, source_project: project, milestone: milestone)
visit_merge_requests(project)
filter_by_milestone(Milestone::Upcoming.title)
expect(page).to have_css('.merge-request', count: 1)
end
it 'should not show issues in past' do
milestone = create(:milestone, project: project, due_date: Date.yesterday)
create(:merge_request, :with_diffs, source_project: project)
create(:merge_request, :simple, source_project: project, milestone: milestone)
visit_merge_requests(project)
filter_by_milestone(Milestone::Upcoming.title)
expect(page).to have_css('.merge-request', count: 0)
end
end
scenario 'filters by a specific Milestone', js: true do
......@@ -21,7 +55,7 @@ feature 'Merge Request filtering by Milestone', feature: true do
visit_merge_requests(project)
filter_by_milestone(milestone.title)
expect(page).to have_css('.merge-request-title', count: 1)
expect(page).to have_css('.merge-request', count: 1)
end
def visit_merge_requests(project)
......
require 'spec_helper'
describe "Internal Project Snippets Access", feature: true do
include AccessMatchers
let(:project) { create(:project, :internal) }
let(:owner) { project.owner }
let(:master) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:internal_snippet) { create(:project_snippet, :internal, project: project, author: owner) }
let(:private_snippet) { create(:project_snippet, :private, project: project, author: owner) }
before do
project.team << [master, :master]
project.team << [developer, :developer]
project.team << [reporter, :reporter]
project.team << [guest, :guest]
end
describe "GET /:project_path/snippets" do
subject { namespace_project_snippets_path(project.namespace, project) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_allowed_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/new" do
subject { new_namespace_project_snippet_path(project.namespace, project) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_denied_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/:id for an internal snippet" do
subject { namespace_project_snippet_path(project.namespace, project, internal_snippet) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_allowed_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/:id for a private snippet" do
subject { namespace_project_snippet_path(project.namespace, project, private_snippet) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
end
require 'spec_helper'
describe "Private Project Snippets Access", feature: true do
include AccessMatchers
let(:project) { create(:project, :private) }
let(:owner) { project.owner }
let(:master) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:private_snippet) { create(:project_snippet, :private, project: project, author: owner) }
before do
project.team << [master, :master]
project.team << [developer, :developer]
project.team << [reporter, :reporter]
project.team << [guest, :guest]
end
describe "GET /:project_path/snippets" do
subject { namespace_project_snippets_path(project.namespace, project) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/new" do
subject { new_namespace_project_snippet_path(project.namespace, project) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_denied_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/:id for a private snippet" do
subject { namespace_project_snippet_path(project.namespace, project, private_snippet) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
end
require 'spec_helper'
describe "Public Project Snippets Access", feature: true do
include AccessMatchers
let(:project) { create(:project, :public) }
let(:owner) { project.owner }
let(:master) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:public_snippet) { create(:project_snippet, :public, project: project, author: owner) }
let(:internal_snippet) { create(:project_snippet, :internal, project: project, author: owner) }
let(:private_snippet) { create(:project_snippet, :private, project: project, author: owner) }
before do
project.team << [master, :master]
project.team << [developer, :developer]
project.team << [reporter, :reporter]
project.team << [guest, :guest]
end
describe "GET /:project_path/snippets" do
subject { namespace_project_snippets_path(project.namespace, project) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_allowed_for :user }
it { is_expected.to be_allowed_for :external }
it { is_expected.to be_allowed_for :visitor }
end
describe "GET /:project_path/snippets/new" do
subject { new_namespace_project_snippet_path(project.namespace, project) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_denied_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/:id for a public snippet" do
subject { namespace_project_snippet_path(project.namespace, project, public_snippet) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_allowed_for :user }
it { is_expected.to be_allowed_for :external }
it { is_expected.to be_allowed_for :visitor }
end
describe "GET /:project_path/snippets/:id for an internal snippet" do
subject { namespace_project_snippet_path(project.namespace, project, internal_snippet) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_allowed_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
describe "GET /:project_path/snippets/:id for a private snippet" do
subject { namespace_project_snippet_path(project.namespace, project, private_snippet) }
it { is_expected.to be_allowed_for :admin }
it { is_expected.to be_allowed_for owner }
it { is_expected.to be_allowed_for master }
it { is_expected.to be_allowed_for developer }
it { is_expected.to be_allowed_for reporter }
it { is_expected.to be_allowed_for guest }
it { is_expected.to be_denied_for :user }
it { is_expected.to be_denied_for :external }
it { is_expected.to be_denied_for :visitor }
end
end
Return-Path: <jake@adventuretime.ooo>
Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
Date: Thu, 13 Jun 2013 17:03:48 -0400
From: Jake the Dog <jake@adventuretime.ooo>
To: reply@appmail.adventuretime.ooo
Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
In-Reply-To: <issue_1@localhost>
References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>
Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
Mime-Version: 1.0
Content-Type: text/plain;
charset=ISO-8859-1
Content-Transfer-Encoding: 7bit
X-Sieve: CMU Sieve 2.2
X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
13 Jun 2013 14:03:48 -0700 (PDT)
X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
I could not disagree more. I am obviously biased but adventure time is the
greatest show ever created. Everyone should watch it.
- Jake out
On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote:
>
>
>
> eviltrout posted in 'Adventure Time Sux' on Discourse Meta:
>
> ---
> hey guys everyone knows adventure time sucks!
>
> ---
> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
>
> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
>
......@@ -7,6 +7,8 @@ Date: Thu, 13 Jun 2013 17:03:48 -0400
From: Jake the Dog <jake@adventuretime.ooo>
To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo
Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
In-Reply-To: <issue_1@localhost>
References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>
Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
Mime-Version: 1.0
Content-Type: text/plain;
......@@ -37,4 +39,4 @@ On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
>
> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
>
\ No newline at end of file
>
require 'spec_helper'
describe AwardEmoji do
describe '.urls' do
subject { AwardEmoji.urls }
it { is_expected.to be_an_instance_of(Array) }
it { is_expected.to_not be_empty }
context 'every Hash in the Array' do
it 'has the correct keys and values' do
subject.each do |hash|
expect(hash[:name]).to be_an_instance_of(String)
expect(hash[:path]).to be_an_instance_of(String)
end
end
end
end
end
require 'spec_helper'
describe Gitlab::Badge::Build do
let(:project) { create(:project) }
let(:sha) { project.commit.sha }
let(:badge) { described_class.new(project, 'master') }
describe '#type' do
subject { badge.type }
it { is_expected.to eq 'image/svg+xml' }
end
context 'build exists' do
let(:ci_commit) { create(:ci_commit, project: project, sha: sha) }
let!(:build) { create(:ci_build, commit: ci_commit) }
context 'build success' do
before { build.success! }
describe '#to_s' do
subject { badge.to_s }
it { is_expected.to eq 'build-success' }
end
describe '#data' do
let(:data) { badge.data }
it 'contains infromation about success' do
expect(status_node(data, 'success')).to be_truthy
end
end
end
context 'build failed' do
before { build.drop! }
describe '#to_s' do
subject { badge.to_s }
it { is_expected.to eq 'build-failed' }
end
describe '#data' do
let(:data) { badge.data }
it 'contains infromation about failure' do
expect(status_node(data, 'failed')).to be_truthy
end
end
end
end
context 'build does not exist' do
describe '#to_s' do
subject { badge.to_s }
it { is_expected.to eq 'build-unknown' }
end
describe '#data' do
let(:data) { badge.data }
it 'contains infromation about unknown build' do
expect(status_node(data, 'unknown')).to be_truthy
end
end
end
def status_node(data, status)
xml = Nokogiri::XML.parse(data)
xml.at(%Q{text:contains("#{status}")})
end
end
......@@ -3,6 +3,7 @@ require "spec_helper"
describe Gitlab::Email::Receiver, lib: true do
before do
stub_incoming_email_setting(enabled: true, address: "reply+%{key}@appmail.adventuretime.ooo")
stub_config_setting(host: 'localhost')
end
let(:reply_key) { "59d8df8370b7e95c5a49fbf86aeb2c93" }
......@@ -137,5 +138,27 @@ describe Gitlab::Email::Receiver, lib: true do
expect(note.note).to include(markdown)
end
context 'when sub-addressing is not supported' do
before do
stub_incoming_email_setting(enabled: true, address: nil)
end
shared_examples 'an email that contains a reply key' do |header|
it "fetches the reply key from the #{header} header and creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
note = noteable.notes.last
expect(note.author).to eq(sent_notification.recipient)
expect(note.note).to include('I could not disagree more.')
end
end
context 'reply key is in the References header' do
let(:email_raw) { fixture_file('emails/reply_without_subaddressing_and_key_inside_references.eml') }
it_behaves_like 'an email that contains a reply key', 'References'
end
end
end
end
......@@ -7,24 +7,8 @@ describe Gitlab::IncomingEmail, lib: true do
stub_incoming_email_setting(enabled: true)
end
context "when the address is valid" do
before do
stub_incoming_email_setting(address: "replies+%{key}@example.com")
end
it "returns true" do
expect(described_class.enabled?).to be_truthy
end
end
context "when the address is invalid" do
before do
stub_incoming_email_setting(address: "replies@example.com")
end
it "returns false" do
expect(described_class.enabled?).to be_falsey
end
it 'returns true' do
expect(described_class.enabled?).to be_truthy
end
end
......@@ -58,4 +42,10 @@ describe Gitlab::IncomingEmail, lib: true do
expect(described_class.key_from_address("replies+key@example.com")).to eq("key")
end
end
context 'self.key_from_fallback_reply_message_id' do
it 'returns reply key' do
expect(described_class.key_from_fallback_reply_message_id('reply-key@localhost')).to eq('key')
end
end
end
......@@ -35,7 +35,9 @@ describe Notify do
subject { Notify.new_issue_email(issue.assignee_id, issue.id) }
it_behaves_like 'an assignee email'
it_behaves_like 'an email starting a new thread', 'issue'
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
let(:model) { issue }
end
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
......@@ -73,9 +75,11 @@ describe Notify do
subject { Notify.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread', 'issue'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like "an unsubscribeable thread"
it_behaves_like 'an unsubscribeable thread'
it 'is sent as the author' do
sender = subject.header[:from].addrs[0]
......@@ -104,7 +108,9 @@ describe Notify do
subject { Notify.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread', 'issue'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'an email with a labels subscriptions link in its footer'
......@@ -132,7 +138,9 @@ describe Notify do
let(:status) { 'closed' }
subject { Notify.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) }
it_behaves_like 'an answer to an existing thread', 'issue'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
......@@ -163,7 +171,9 @@ describe Notify do
let(:new_issue) { create(:issue) }
subject { Notify.issue_moved_email(recipient, issue, new_issue, current_user) }
it_behaves_like 'an answer to an existing thread', 'issue'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
......@@ -196,9 +206,11 @@ describe Notify do
subject { Notify.new_merge_request_email(merge_request.assignee_id, merge_request.id) }
it_behaves_like 'an assignee email'
it_behaves_like 'an email starting a new thread', 'merge_request'
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
it_behaves_like 'an unsubscribeable thread'
it 'has the correct subject' do
is_expected.to have_subject /#{merge_request.title} \(##{merge_request.iid}\)/
......@@ -216,10 +228,6 @@ describe Notify do
is_expected.to have_body_text /#{merge_request.target_branch}/
end
it 'has the correct message-id set' do
is_expected.to have_header 'Message-ID', "<merge_request_#{merge_request.id}@#{Gitlab.config.gitlab.host}>"
end
context 'when enabled email_author_in_body' do
before do
allow(current_application_settings).to receive(:email_author_in_body).and_return(true)
......@@ -247,7 +255,9 @@ describe Notify do
subject { Notify.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread', 'merge_request'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
......@@ -278,7 +288,9 @@ describe Notify do
subject { Notify.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread', 'merge_request'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'an email with a labels subscriptions link in its footer'
......@@ -306,9 +318,11 @@ describe Notify do
let(:status) { 'reopened' }
subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) }
it_behaves_like 'an answer to an existing thread', 'merge_request'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
it_behaves_like 'an unsubscribeable thread'
it 'is sent as the author' do
sender = subject.header[:from].addrs[0]
......@@ -337,9 +351,11 @@ describe Notify do
subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread', 'merge_request'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
it_behaves_like 'an unsubscribeable thread'
it 'is sent as the merge author' do
sender = subject.header[:from].addrs[0]
......@@ -456,9 +472,11 @@ describe Notify do
subject { Notify.note_commit_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an answer to an existing thread', 'commit'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { commit }
end
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like "a user cannot unsubscribe through footer link"
it_behaves_like 'a user cannot unsubscribe through footer link'
it 'has the correct subject' do
is_expected.to have_subject /#{commit.title} \(#{commit.short_id}\)/
......@@ -477,7 +495,9 @@ describe Notify do
subject { Notify.note_merge_request_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an answer to an existing thread', 'merge_request'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
......@@ -498,7 +518,9 @@ describe Notify do
subject { Notify.note_issue_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an answer to an existing thread', 'issue'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
......
......@@ -10,6 +10,13 @@ shared_context 'gitlab email notification' do
ActionMailer::Base.deliveries.clear
email = recipient.emails.create(email: "notifications@example.com")
recipient.update_attribute(:notification_email, email.email)
stub_incoming_email_setting(enabled: true, address: "reply+%{key}@#{Gitlab.config.gitlab.host}")
end
end
shared_context 'reply-by-email is enabled with incoming address without %{key}' do
before do
stub_incoming_email_setting(enabled: true, address: "reply@#{Gitlab.config.gitlab.host}")
end
end
......@@ -46,25 +53,76 @@ shared_examples 'an email with X-GitLab headers containing project details' do
end
end
shared_examples 'an email starting a new thread' do |message_id_prefix|
include_examples 'an email with X-GitLab headers containing project details'
shared_examples 'a new thread email with reply-by-email enabled' do
let(:regex) { /\A<reply\-(.*)@#{Gitlab.config.gitlab.host}>\Z/ }
it 'has a Message-ID header' do
is_expected.to have_header 'Message-ID', "<#{model.class.model_name.singular_route_key}_#{model.id}@#{Gitlab.config.gitlab.host}>"
end
it 'has a discussion identifier' do
is_expected.to have_header 'Message-ID', /<#{message_id_prefix}(.*)@#{Gitlab.config.gitlab.host}>/
it 'has a References header' do
is_expected.to have_header 'References', regex
end
end
shared_examples 'an answer to an existing thread' do |thread_id_prefix|
shared_examples 'a thread answer email with reply-by-email enabled' do
include_examples 'an email with X-GitLab headers containing project details'
let(:regex) { /\A<#{model.class.model_name.singular_route_key}_#{model.id}@#{Gitlab.config.gitlab.host}> <reply\-(.*)@#{Gitlab.config.gitlab.host}>\Z/ }
it 'has a Message-ID header' do
is_expected.to have_header 'Message-ID', /\A<(.*)@#{Gitlab.config.gitlab.host}>\Z/
end
it 'has a In-Reply-To header' do
is_expected.to have_header 'In-Reply-To', "<#{model.class.model_name.singular_route_key}_#{model.id}@#{Gitlab.config.gitlab.host}>"
end
it 'has a References header' do
is_expected.to have_header 'References', regex
end
it 'has a subject that begins with Re: ' do
is_expected.to have_subject /^Re: /
end
end
shared_examples 'an email starting a new thread with reply-by-email enabled' do
include_examples 'an email with X-GitLab headers containing project details'
include_examples 'a new thread email with reply-by-email enabled'
context 'when reply-by-email is enabled with incoming address with %{key}' do
it 'has a Reply-To header' do
is_expected.to have_header 'Reply-To', /<reply+(.*)@#{Gitlab.config.gitlab.host}>\Z/
end
end
context 'when reply-by-email is enabled with incoming address without %{key}' do
include_context 'reply-by-email is enabled with incoming address without %{key}'
include_examples 'a new thread email with reply-by-email enabled'
it 'has a Reply-To header' do
is_expected.to have_header 'Reply-To', /<reply@#{Gitlab.config.gitlab.host}>\Z/
end
end
end
shared_examples 'an answer to an existing thread with reply-by-email enabled' do
include_examples 'an email with X-GitLab headers containing project details'
include_examples 'a thread answer email with reply-by-email enabled'
context 'when reply-by-email is enabled with incoming address with %{key}' do
it 'has a Reply-To header' do
is_expected.to have_header 'Reply-To', /<reply+(.*)@#{Gitlab.config.gitlab.host}>\Z/
end
end
context 'when reply-by-email is enabled with incoming address without %{key}' do
include_context 'reply-by-email is enabled with incoming address without %{key}'
include_examples 'a thread answer email with reply-by-email enabled'
it 'has headers that reference an existing thread' do
is_expected.to have_header 'Message-ID', /<(.*)@#{Gitlab.config.gitlab.host}>/
is_expected.to have_header 'References', /<#{thread_id_prefix}(.*)@#{Gitlab.config.gitlab.host}>/
is_expected.to have_header 'In-Reply-To', /<#{thread_id_prefix}(.*)@#{Gitlab.config.gitlab.host}>/
it 'has a Reply-To header' do
is_expected.to have_header 'Reply-To', /<reply@#{Gitlab.config.gitlab.host}>\Z/
end
end
end
......
......@@ -20,24 +20,27 @@ require "spec_helper"
describe SystemHook, models: true do
describe "execute" do
before(:each) do
@system_hook = create(:system_hook)
WebMock.stub_request(:post, @system_hook.url)
let(:system_hook) { create(:system_hook) }
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) }
let(:group) { create(:group) }
before do
WebMock.stub_request(:post, system_hook.url)
end
it "project_create hook" do
Projects::CreateService.new(create(:user), name: 'empty').execute
expect(WebMock).to have_requested(:post, @system_hook.url).with(
Projects::CreateService.new(user, name: 'empty').execute
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /project_create/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it "project_destroy hook" do
user = create(:user)
project = create(:empty_project, namespace: user.namespace)
Projects::DestroyService.new(project, user, {}).pending_delete!
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /project_destroy/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
......@@ -45,37 +48,36 @@ describe SystemHook, models: true do
it "user_create hook" do
create(:user)
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_create/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it "user_destroy hook" do
user = create(:user)
user.destroy
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_destroy/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it "project_create hook" do
user = create(:user)
project = create(:project)
project.team << [user, :master]
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_add_to_team/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it "project_destroy hook" do
user = create(:user)
project = create(:project)
project.team << [user, :master]
project.project_members.destroy_all
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_remove_from_team/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
......@@ -83,41 +85,39 @@ describe SystemHook, models: true do
it 'group create hook' do
create(:group)
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /group_create/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it 'group destroy hook' do
group = create(:group)
group.destroy
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /group_destroy/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it 'group member create hook' do
group = create(:group)
user = create(:user)
group.add_master(user)
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_add_to_group/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
it 'group member destroy hook' do
group = create(:group)
user = create(:user)
group.add_master(user)
group.group_members.destroy_all
expect(WebMock).to have_requested(:post, @system_hook.url).with(
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_remove_from_group/,
headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'System Hook' }
).once
end
end
end
......@@ -224,22 +224,22 @@ describe MergeRequest, models: true do
['WIP ', 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', ' [WIP] WIP [WIP] WIP: WIP '].each do |wip_prefix|
it "detects the '#{wip_prefix}' prefix" do
subject.title = "#{wip_prefix}#{subject.title}"
expect(subject).to be_work_in_progress
expect(subject.work_in_progress?).to eq true
end
end
it "doesn't detect WIP for words starting with WIP" do
subject.title = "Wipwap #{subject.title}"
expect(subject).not_to be_work_in_progress
expect(subject.work_in_progress?).to eq false
end
it "doesn't detect WIP for words containing with WIP" do
subject.title = "WupWipwap #{subject.title}"
expect(subject).not_to be_work_in_progress
expect(subject.work_in_progress?).to eq false
end
it "doesn't detect WIP by default" do
expect(subject).not_to be_work_in_progress
expect(subject.work_in_progress?).to eq false
end
end
......
......@@ -27,6 +27,16 @@ describe SlackService::IssueMessage, models: true do
let(:color) { '#345' }
context '#initialize' do
before do
args[:object_attributes][:description] = nil
end
it 'returns a non-null description' do
expect(subject.description).to eq('')
end
end
context 'open' do
it 'returns a message regarding opening of issues' do
expect(subject.pretext).to eq(
......
......@@ -23,13 +23,25 @@ describe API::API, api: true do
end
describe 'POST /projects/:id/labels' do
it 'should return created label' do
it 'should return created label when all params' do
post api("/projects/#{project.id}/labels", user),
name: 'Foo',
color: '#FFAABB',
description: 'test'
expect(response.status).to eq(201)
expect(json_response['name']).to eq('Foo')
expect(json_response['color']).to eq('#FFAABB')
expect(json_response['description']).to eq('test')
end
it 'should return created label when only required params' do
post api("/projects/#{project.id}/labels", user),
name: 'Foo',
color: '#FFAABB'
expect(response.status).to eq(201)
expect(json_response['name']).to eq('Foo')
expect(json_response['color']).to eq('#FFAABB')
expect(json_response['description']).to be_nil
end
it 'should return a 400 bad request if name not given' do
......@@ -94,14 +106,16 @@ describe API::API, api: true do
end
describe 'PUT /projects/:id/labels' do
it 'should return 200 if name and colors are changed' do
it 'should return 200 if name and colors and description are changed' do
put api("/projects/#{project.id}/labels", user),
name: 'label1',
new_name: 'New Label',
color: '#FFFFFF'
color: '#FFFFFF',
description: 'test'
expect(response.status).to eq(200)
expect(json_response['name']).to eq('New Label')
expect(json_response['color']).to eq('#FFFFFF')
expect(json_response['description']).to eq('test')
end
it 'should return 200 if name is changed' do
......@@ -122,6 +136,15 @@ describe API::API, api: true do
expect(json_response['color']).to eq('#FFFFFF')
end
it 'should return 200 if description is changed' do
put api("/projects/#{project.id}/labels", user),
name: 'label1',
description: 'test'
expect(response.status).to eq(200)
expect(json_response['name']).to eq(label1.name)
expect(json_response['description']).to eq('test')
end
it 'should return 404 if label does not exist' do
put api("/projects/#{project.id}/labels", user),
name: 'label2',
......
......@@ -118,6 +118,7 @@ describe API::API, api: true do
expect(response.status).to eq(200)
expect(json_response['title']).to eq(merge_request.title)
expect(json_response['iid']).to eq(merge_request.iid)
expect(json_response['work_in_progress']).to eq(false)
expect(json_response['merge_status']).to eq('can_be_merged')
end
......@@ -133,6 +134,16 @@ describe API::API, api: true do
get api("/projects/#{project.id}/merge_requests/999", user)
expect(response.status).to eq(404)
end
context 'Work in Progress' do
let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) }
it "should return merge_request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.id}", user)
expect(response.status).to eq(200)
expect(json_response['work_in_progress']).to eq(true)
end
end
end
describe 'GET /projects/:id/merge_requests/:merge_request_id/commits' do
......
CarrierWave.root = 'tmp/tests/uploads'
RSpec.configure do |config|
config.after(:suite) do
FileUtils.rm_rf('tmp/tests/uploads')
end
end
......@@ -7,8 +7,12 @@ describe 'gitlab:app namespace rake task' do
Rake.application.rake_require 'tasks/gitlab/backup'
Rake.application.rake_require 'tasks/gitlab/shell'
Rake.application.rake_require 'tasks/gitlab/db'
# empty task as env is already loaded
Rake::Task.define_task :environment
# We need this directory to run `gitlab:backup:create` task
FileUtils.mkdir_p('public/uploads')
end
def run_rake_task(task_name)
......
@charset "UTF-8";
/*!
* animate.css -http://daneden.me/animate
* Version - 3.5.1
* Licensed under the MIT license - http://opensource.org/licenses/MIT
*
* Copyright (c) 2016 Daniel Eden
*/
.animated{-webkit-animation-duration:1s;animation-duration:1s;-webkit-animation-fill-mode:both;animation-fill-mode:both}.animated.infinite{-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.animated.hinge{-webkit-animation-duration:2s;animation-duration:2s}.animated.bounceIn,.animated.bounceOut,.animated.flipOutX,.animated.flipOutY{-webkit-animation-duration:.75s;animation-duration:.75s}@-webkit-keyframes bounce{0%,20%,53%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-transform:translate3d(0,-30px,0);transform:translate3d(0,-30px,0)}40%,43%,70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06)}70%{-webkit-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}90%{-webkit-transform:translate3d(0,-4px,0);transform:translate3d(0,-4px,0)}}@keyframes bounce{0%,20%,53%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-transform:translate3d(0,-30px,0);transform:translate3d(0,-30px,0)}40%,43%,70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06)}70%{-webkit-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}90%{-webkit-transform:translate3d(0,-4px,0);transform:translate3d(0,-4px,0)}}.bounce{-webkit-animation-name:bounce;animation-name:bounce;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}.flash{-webkit-animation-name:flash;animation-name:flash}@-webkit-keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.pulse{-webkit-animation-name:pulse;animation-name:pulse}@-webkit-keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.rubberBand{-webkit-animation-name:rubberBand;animation-name:rubberBand}@-webkit-keyframes shake{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}@keyframes shake{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}.shake{-webkit-animation-name:shake;animation-name:shake}@-webkit-keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}@keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}.headShake{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-name:headShake;animation-name:headShake}@-webkit-keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}.swing{-webkit-transform-origin:top center;transform-origin:top center;-webkit-animation-name:swing;animation-name:swing}@-webkit-keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.tada{-webkit-animation-name:tada;animation-name:tada}@-webkit-keyframes wobble{0%{-webkit-transform:none;transform:none}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:none;transform:none}}@keyframes wobble{0%{-webkit-transform:none;transform:none}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:none;transform:none}}.wobble{-webkit-animation-name:wobble;animation-name:wobble}@-webkit-keyframes jello{0%,11.1%,to{-webkit-transform:none;transform:none}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}@keyframes jello{0%,11.1%,to{-webkit-transform:none;transform:none}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}.jello{-webkit-animation-name:jello;animation-name:jello;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}.bounceIn{-webkit-animation-name:bounceIn;animation-name:bounceIn}@-webkit-keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0);transform:translate3d(0,-3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0);transform:translate3d(0,25px,0)}75%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}90%{-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}to{-webkit-transform:none;transform:none}}@keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0);transform:translate3d(0,-3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0);transform:translate3d(0,25px,0)}75%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}90%{-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}to{-webkit-transform:none;transform:none}}.bounceInDown{-webkit-animation-name:bounceInDown;animation-name:bounceInDown}@-webkit-keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0);transform:translate3d(-3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0);transform:translate3d(25px,0,0)}75%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}90%{-webkit-transform:translate3d(5px,0,0);transform:translate3d(5px,0,0)}to{-webkit-transform:none;transform:none}}@keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0);transform:translate3d(-3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0);transform:translate3d(25px,0,0)}75%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}90%{-webkit-transform:translate3d(5px,0,0);transform:translate3d(5px,0,0)}to{-webkit-transform:none;transform:none}}.bounceInLeft{-webkit-animation-name:bounceInLeft;animation-name:bounceInLeft}@-webkit-keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0);transform:translate3d(3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0);transform:translate3d(-25px,0,0)}75%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}90%{-webkit-transform:translate3d(-5px,0,0);transform:translate3d(-5px,0,0)}to{-webkit-transform:none;transform:none}}@keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0);transform:translate3d(3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0);transform:translate3d(-25px,0,0)}75%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}90%{-webkit-transform:translate3d(-5px,0,0);transform:translate3d(-5px,0,0)}to{-webkit-transform:none;transform:none}}.bounceInRight{-webkit-animation-name:bounceInRight;animation-name:bounceInRight}@-webkit-keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0);transform:translate3d(0,3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}75%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}90%{-webkit-transform:translate3d(0,-5px,0);transform:translate3d(0,-5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0);transform:translate3d(0,3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}75%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}90%{-webkit-transform:translate3d(0,-5px,0);transform:translate3d(0,-5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.bounceInUp{-webkit-animation-name:bounceInUp;animation-name:bounceInUp}@-webkit-keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}@keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}.bounceOut{-webkit-animation-name:bounceOut;animation-name:bounceOut}@-webkit-keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.bounceOutDown{-webkit-animation-name:bounceOutDown;animation-name:bounceOutDown}@-webkit-keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.bounceOutLeft{-webkit-animation-name:bounceOutLeft;animation-name:bounceOutLeft}@-webkit-keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0);transform:translate3d(-20px,0,0)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0);transform:translate3d(-20px,0,0)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.bounceOutRight{-webkit-animation-name:bounceOutRight;animation-name:bounceOutRight}@-webkit-keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.bounceOutUp{-webkit-animation-name:bounceOutUp;animation-name:bounceOutUp}@-webkit-keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}@-webkit-keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInDown{-webkit-animation-name:fadeInDown;animation-name:fadeInDown}@-webkit-keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInDownBig{-webkit-animation-name:fadeInDownBig;animation-name:fadeInDownBig}@-webkit-keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInLeft{-webkit-animation-name:fadeInLeft;animation-name:fadeInLeft}@-webkit-keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInLeftBig{-webkit-animation-name:fadeInLeftBig;animation-name:fadeInLeftBig}@-webkit-keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInRight{-webkit-animation-name:fadeInRight;animation-name:fadeInRight}@-webkit-keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInRightBig{-webkit-animation-name:fadeInRightBig;animation-name:fadeInRightBig}@-webkit-keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInUp{-webkit-animation-name:fadeInUp;animation-name:fadeInUp}@-webkit-keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInUpBig{-webkit-animation-name:fadeInUpBig;animation-name:fadeInUpBig}@-webkit-keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}.fadeOut{-webkit-animation-name:fadeOut;animation-name:fadeOut}@-webkit-keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.fadeOutDown{-webkit-animation-name:fadeOutDown;animation-name:fadeOutDown}@-webkit-keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.fadeOutDownBig{-webkit-animation-name:fadeOutDownBig;animation-name:fadeOutDownBig}@-webkit-keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.fadeOutLeft{-webkit-animation-name:fadeOutLeft;animation-name:fadeOutLeft}@-webkit-keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.fadeOutLeftBig{-webkit-animation-name:fadeOutLeftBig;animation-name:fadeOutLeftBig}@-webkit-keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.fadeOutRight{-webkit-animation-name:fadeOutRight;animation-name:fadeOutRight}@-webkit-keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.fadeOutRightBig{-webkit-animation-name:fadeOutRightBig;animation-name:fadeOutRightBig}@-webkit-keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.fadeOutUp{-webkit-animation-name:fadeOutUp;animation-name:fadeOutUp}@-webkit-keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.fadeOutUpBig{-webkit-animation-name:fadeOutUpBig;animation-name:fadeOutUpBig}@-webkit-keyframes flip{0%{-webkit-transform:perspective(400px) rotateY(-1turn);transform:perspective(400px) rotateY(-1turn)}0%,40%{-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-190deg);transform:perspective(400px) translateZ(150px) rotateY(-190deg)}50%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-170deg);transform:perspective(400px) translateZ(150px) rotateY(-170deg)}50%,80%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95);transform:perspective(400px) scale3d(.95,.95,.95)}to{-webkit-transform:perspective(400px);transform:perspective(400px);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}@keyframes flip{0%{-webkit-transform:perspective(400px) rotateY(-1turn);transform:perspective(400px) rotateY(-1turn)}0%,40%{-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-190deg);transform:perspective(400px) translateZ(150px) rotateY(-190deg)}50%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-170deg);transform:perspective(400px) translateZ(150px) rotateY(-170deg)}50%,80%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95);transform:perspective(400px) scale3d(.95,.95,.95)}to{-webkit-transform:perspective(400px);transform:perspective(400px);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}.animated.flip{-webkit-backface-visibility:visible;backface-visibility:visible;-webkit-animation-name:flip;animation-name:flip}@-webkit-keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg)}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg)}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.flipInX{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInX;animation-name:flipInX}@-webkit-keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg)}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg)}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.flipInY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInY;animation-name:flipInY}@-webkit-keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}@keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}.flipOutX{-webkit-animation-name:flipOutX;animation-name:flipOutX;-webkit-backface-visibility:visible!important;backface-visibility:visible!important}@-webkit-keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}@keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}.flipOutY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipOutY;animation-name:flipOutY}@-webkit-keyframes lightSpeedIn{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg)}60%,80%{opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:none;transform:none;opacity:1}}@keyframes lightSpeedIn{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg)}60%,80%{opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:none;transform:none;opacity:1}}.lightSpeedIn{-webkit-animation-name:lightSpeedIn;animation-name:lightSpeedIn;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedOut{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}@keyframes lightSpeedOut{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}.lightSpeedOut{-webkit-animation-name:lightSpeedOut;animation-name:lightSpeedOut;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes rotateIn{0%{transform-origin:center;-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateIn{0%{transform-origin:center;-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:none;transform:none;opacity:1}}.rotateIn{-webkit-animation-name:rotateIn;animation-name:rotateIn}@-webkit-keyframes rotateInDownLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInDownLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInDownLeft{-webkit-animation-name:rotateInDownLeft;animation-name:rotateInDownLeft}@-webkit-keyframes rotateInDownRight{0%{transform-origin:right bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInDownRight{0%{transform-origin:right bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInDownRight{-webkit-animation-name:rotateInDownRight;animation-name:rotateInDownRight}@-webkit-keyframes rotateInUpLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInUpLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInUpLeft{-webkit-animation-name:rotateInUpLeft;animation-name:rotateInUpLeft}@-webkit-keyframes rotateInUpRight{0%{transform-origin:right bottom;-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInUpRight{0%{transform-origin:right bottom;-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInUpRight{-webkit-animation-name:rotateInUpRight;animation-name:rotateInUpRight}@-webkit-keyframes rotateOut{0%{transform-origin:center;opacity:1}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}@keyframes rotateOut{0%{transform-origin:center;opacity:1}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}.rotateOut{-webkit-animation-name:rotateOut;animation-name:rotateOut}@-webkit-keyframes rotateOutDownLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}@keyframes rotateOutDownLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}.rotateOutDownLeft{-webkit-animation-name:rotateOutDownLeft;animation-name:rotateOutDownLeft}@-webkit-keyframes rotateOutDownRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutDownRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.rotateOutDownRight{-webkit-animation-name:rotateOutDownRight;animation-name:rotateOutDownRight}@-webkit-keyframes rotateOutUpLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutUpLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.rotateOutUpLeft{-webkit-animation-name:rotateOutUpLeft;animation-name:rotateOutUpLeft}@-webkit-keyframes rotateOutUpRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}@keyframes rotateOutUpRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}.rotateOutUpRight{-webkit-animation-name:rotateOutUpRight;animation-name:rotateOutUpRight}@-webkit-keyframes hinge{0%{transform-origin:top left}0%,20%,60%{-webkit-transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);transform-origin:top left}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}@keyframes hinge{0%{transform-origin:top left}0%,20%,60%{-webkit-transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);transform-origin:top left}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}.hinge{-webkit-animation-name:hinge;animation-name:hinge}@-webkit-keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:none;transform:none}}.rollIn{-webkit-animation-name:rollIn;animation-name:rollIn}@-webkit-keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}@keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}.rollOut{-webkit-animation-name:rollOut;animation-name:rollOut}@-webkit-keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}.zoomIn{-webkit-animation-name:zoomIn;animation-name:zoomIn}@-webkit-keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInDown{-webkit-animation-name:zoomInDown;animation-name:zoomInDown}@-webkit-keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInLeft{-webkit-animation-name:zoomInLeft;animation-name:zoomInLeft}@-webkit-keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInRight{-webkit-animation-name:zoomInRight;animation-name:zoomInRight}@-webkit-keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInUp{-webkit-animation-name:zoomInUp;animation-name:zoomInUp}@-webkit-keyframes zoomOut{0%{opacity:1}50%{-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%,to{opacity:0}}@keyframes zoomOut{0%{opacity:1}50%{-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%,to{opacity:0}}.zoomOut{-webkit-animation-name:zoomOut;animation-name:zoomOut}@-webkit-keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomOutDown{-webkit-animation-name:zoomOutDown;animation-name:zoomOutDown}@-webkit-keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0);-webkit-transform-origin:left center;transform-origin:left center}}@keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0);-webkit-transform-origin:left center;transform-origin:left center}}.zoomOutLeft{-webkit-animation-name:zoomOutLeft;animation-name:zoomOutLeft}@-webkit-keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0);-webkit-transform-origin:right center;transform-origin:right center}}@keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0);-webkit-transform-origin:right center;transform-origin:right center}}.zoomOutRight{-webkit-animation-name:zoomOutRight;animation-name:zoomOutRight}@-webkit-keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomOutUp{-webkit-animation-name:zoomOutUp;animation-name:zoomOutUp}@-webkit-keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInDown{-webkit-animation-name:slideInDown;animation-name:slideInDown}@-webkit-keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInLeft{-webkit-animation-name:slideInLeft;animation-name:slideInLeft}@-webkit-keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInRight{-webkit-animation-name:slideInRight;animation-name:slideInRight}@-webkit-keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInUp{-webkit-animation-name:slideInUp;animation-name:slideInUp}@-webkit-keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.slideOutDown{-webkit-animation-name:slideOutDown;animation-name:slideOutDown}@-webkit-keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.slideOutLeft{-webkit-animation-name:slideOutLeft;animation-name:slideOutLeft}@-webkit-keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.slideOutRight{-webkit-animation-name:slideOutRight;animation-name:slideOutRight}@-webkit-keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.slideOutUp{-webkit-animation-name:slideOutUp;animation-name:slideOutUp}
\ No newline at end of file
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