Commit 59064aee authored by Zeger-Jan van de Weg's avatar Zeger-Jan van de Weg

Merge branch 'master' into 4009-external-users

parents aaf4434b bc590ce6
{
"always-semicolon": true,
"color-case": "lower",
"block-indent": " ",
"color-shorthand": true,
"element-case": "lower",
"space-before-colon": "",
"space-after-colon": " ",
"space-before-combinator": " ",
"space-after-combinator": " ",
"space-between-declarations": "\n",
"space-before-opening-brace": " ",
"space-after-opening-brace": "\n",
"space-before-closing-brace": "\n",
"unitless-zero": true
}
......@@ -122,6 +122,14 @@ rubocop:
- ruby
- mysql
scss-lint:
stage: test
script:
- bundle exec rake scss_lint
tags:
- ruby
allow_failure: true
brakeman:
stage: test
script:
......@@ -148,13 +156,14 @@ flay:
bundler:audit:
stage: test
only:
- master
script:
- "bundle exec bundle-audit update"
- "bundle exec bundle-audit check"
- "bundle exec bundle-audit check --ignore OSVDB-115941"
tags:
- ruby
- mysql
allow_failure: true
# Ruby 2.2 jobs
......@@ -162,7 +171,7 @@ spec:feature:ruby22:
stage: test
image: ruby:2.2
only:
- master
- master
script:
- RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null
- RAILS_ENV=test SIMPLECOV=true bundle exec rake spec:feature
......
# Linter Documentation:
# https://github.com/brigade/scss-lint/blob/master/lib/scss_lint/linter/README.md
scss_files: 'app/assets/stylesheets/**/*.scss'
exclude:
- 'app/assets/stylesheets/pages/emojis.scss'
linters:
BangFormat:
enabled: false
BorderZero:
enabled: false
ColorKeyword:
enabled: false
ColorVariable:
enabled: false
Comment:
enabled: false
DeclarationOrder:
enabled: false
# `scss-lint:disable` control comments should be preceded by a comment
# explaining why these linters are being disabled for this file.
# See https://github.com/brigade/scss-lint#disabling-linters-via-source for
# more information.
DisableLinterReason:
enabled: true
DuplicateProperty:
enabled: false
EmptyLineBetweenBlocks:
enabled: false
EmptyRule:
enabled: false
FinalNewline:
enabled: false
# HEX colors should use three-character values where possible.
HexLength:
enabled: true
# HEX color values should use lower-case colors to differentiate between
# letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`.
HexNotation:
enabled: true
IdSelector:
enabled: false
ImportPath:
enabled: false
ImportantRule:
enabled: false
# Indentation should always be done in increments of 2 spaces.
Indentation:
enabled: true
width: 2
LeadingZero:
enabled: false
MergeableSelector:
enabled: false
NameFormat:
enabled: false
NestingDepth:
enabled: false
PlaceholderInExtend:
enabled: false
PropertySortOrder:
enabled: false
PropertySpelling:
enabled: false
PseudoElement:
enabled: false
QualifyingElement:
enabled: false
SelectorDepth:
enabled: false
# Selectors should always use hyphenated-lowercase, rather than camelCase or
# snake_case.
SelectorFormat:
enabled: true
convention: hyphenated_lowercase
# Prefer the shortest shorthand form possible for properties that support it.
Shorthand:
enabled: true
# Each property should have its own line, except in the special case of
# single line rulesets.
SingleLinePerProperty:
enabled: true
allow_single_line_rule_sets: true
SingleLinePerSelector:
enabled: false
SpaceAfterComma:
enabled: false
# Properties should be formatted with a single space separating the colon
# from the property's value.
SpaceAfterPropertyColon:
enabled: true
# Properties should be formatted with no space between the name and the
# colon.
SpaceAfterPropertyName:
enabled: true
SpaceAroundOperator:
enabled: false
SpaceBeforeBrace:
enabled: false
StringQuotes:
enabled: false
TrailingSemicolon:
enabled: false
TrailingWhitespace:
enabled: false
UnnecessaryMantissa:
enabled: false
UnnecessaryParentReference:
enabled: false
VendorPrefix:
enabled: false
# Omit length units on zero values, e.g. `0px` vs. `0`.
ZeroUnit:
enabled: true
Please view this file on the master branch, on stable branches it's out of date.
v 8.6.0 (unreleased)
- Bump gitlab_git to 9.0.3 (Stan Hu)
- Support Golang subpackage fetching (Stan Hu)
- Bump Capybara gem to 2.6.2 (Stan Hu)
- Contributions to forked projects are included in calendar
- Improve the formatting for the user page bio (Connor Shea)
- Removed the default password from the initial admin account created during
setup. A password can be provided during setup (see installation docs), or
GitLab will ask the user to create a new one upon first visit.
- Fix issue when pushing to projects ending in .wiki
- Fix avatar stretching by providing a cropping feature (Johann Pardanaud)
- Don't load all of GitLab in mail_room
- Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set
- Memoize @group in Admin::GroupsController (Yatish Mehta)
......@@ -18,12 +19,19 @@ v 8.6.0 (unreleased)
- Return empty array instead of 404 when commit has no statuses in commit status API
- Decrease the font size and the padding of the `.anchor` icons used in the README (Roberto Dip)
- Rewrite logo to simplify SVG code (Sean Lang)
- Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach)
- Ignore jobs that start with `.` (hidden jobs)
- Allow to pass name of created artifacts archive in `.gitlab-ci.yml`
- Refactor and greatly improve search performance
- Add support for cross-project label references
- Ensure "new SSH key" email do not ends up as dead Sidekiq jobs
- Update documentation to reflect Guest role not being enforced on internal projects
- Allow search for logged out users
- Allow to define on which builds the current one depends on
- Allow user subscription to a label: get notified for issues/merge requests related to that label (Timothy Andrew)
- Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio)
- Don't show Issues/MRs from archived projects in Groups view
- Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs
- Increase the notes polling timeout over time (Roberto Dip)
- Add shortcut to toggle markdown preview (Florent Baldino)
- Show labels in dashboard and group milestone views
......@@ -33,6 +41,9 @@ v 8.6.0 (unreleased)
- Create external users which are excluded of internal and private projects unless access was explicitly granted
- Continue parameters are checked to ensure redirection goes to the same instance
v 8.5.6
- Obtain a lease before querying LDAP
v 8.5.5
- Ensure removing a project removes associated Todo entries
- Prevent a 500 error in Todos when author was removed
......
......@@ -427,6 +427,7 @@ merge request:
1. [Rails](https://github.com/bbatsov/rails-style-guide)
1. [Testing](https://github.com/thoughtbot/guides/tree/master/style/testing)
1. [CoffeeScript](https://github.com/thoughtbot/guides/tree/master/style/coffeescript)
1. [SCSS styleguide][scss-styleguide]
1. [Shell commands](doc/development/shell_commands.md) created by GitLab
contributors to enhance security
1. [Database Migrations](doc/development/migration_style_guide.md)
......@@ -494,6 +495,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor
[rss-source]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#source-code-layout
[rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming
[doc-styleguide]: doc/development/doc_styleguide.md "Documentation styleguide"
[scss-styleguide]: doc/development/scss_styleguide.md "SCSS styleguide"
[gitlab-design]: https://gitlab.com/gitlab-org/gitlab-design
[free Antetype viewer (Mac OSX only)]: https://itunes.apple.com/us/app/antetype-viewer/id824152298?mt=12
[`gitlab1.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/gitlab1.atype/
......
......@@ -77,9 +77,6 @@ gem "haml-rails", '~> 0.9.0'
# Files attachments
gem "carrierwave", '~> 0.10.0'
# Image editing
gem "mini_magick", '~> 4.4.0'
# Drag and Drop UI
gem 'dropzonejs-rails', '~> 0.7.1'
......@@ -273,7 +270,7 @@ group :development, :test do
# Generate Fake data
gem 'ffaker', '~> 2.0.0'
gem 'capybara', '~> 2.4.0'
gem 'capybara', '~> 2.6.2'
gem 'capybara-screenshot', '~> 1.0.0'
gem 'poltergeist', '~> 1.9.0'
......@@ -286,6 +283,7 @@ group :development, :test do
gem 'spring-commands-teaspoon', '~> 0.0.2'
gem 'rubocop', '~> 0.35.0', require: false
gem 'scss_lint', '~> 0.47.0', require: false
gem 'coveralls', '~> 0.8.2', require: false
gem 'simplecov', '~> 0.10.0', require: false
gem 'flog', require: false
......
......@@ -108,7 +108,8 @@ GEM
thor (~> 0.18)
byebug (8.2.1)
cal-heatmap-rails (3.5.1)
capybara (2.4.4)
capybara (2.6.2)
addressable
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
......@@ -358,7 +359,7 @@ GEM
posix-spawn (~> 0.3)
gitlab_emoji (0.3.1)
gemojione (~> 2.2, >= 2.2.1)
gitlab_git (9.0.1)
gitlab_git (9.0.3)
activesupport (~> 4.0)
charlock_holmes (~> 0.7.3)
github-linguist (~> 4.7.0)
......@@ -468,7 +469,6 @@ GEM
method_source (0.8.2)
mime-types (1.25.1)
mimemagic (0.3.0)
mini_magick (4.4.0)
mini_portile2 (2.0.0)
minitest (5.7.0)
mousetrap-rails (1.4.6)
......@@ -717,6 +717,9 @@ GEM
sawyer (0.6.0)
addressable (~> 2.3.5)
faraday (~> 0.8, < 0.10)
scss_lint (0.47.1)
rake (>= 0.9, < 11)
sass (~> 3.4.15)
sdoc (0.3.20)
json (>= 1.1.3)
rdoc (~> 3.10)
......@@ -901,7 +904,7 @@ DEPENDENCIES
bundler-audit
byebug
cal-heatmap-rails (~> 3.5.0)
capybara (~> 2.4.0)
capybara (~> 2.6.2)
capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0)
charlock_holmes (~> 0.7.3)
......@@ -956,7 +959,6 @@ DEPENDENCIES
loofah (~> 2.0.3)
mail_room (~> 0.6.1)
method_source (~> 0.8)
mini_magick (~> 4.4.0)
minitest (~> 5.7.0)
mousetrap-rails (~> 1.4.6)
mysql2 (~> 0.3.16)
......@@ -1008,6 +1010,7 @@ DEPENDENCIES
ruby-fogbugz (~> 0.2.1)
sanitize (~> 2.0)
sass-rails (~> 5.0.0)
scss_lint (~> 0.47.0)
sdoc (~> 0.3.20)
seed-fu (~> 2.3.5)
select2-rails (~> 3.5.9)
......
......@@ -42,7 +42,6 @@
#= require jquery.nicescroll
#= require_tree .
#= require fuzzaldrin-plus
#= require cropper.js
window.slugify = (text) ->
text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase()
......@@ -108,6 +107,8 @@ window.onload = ->
setTimeout shiftWindow, 100
$ ->
bootstrapBreakpoint = bp.getBreakpointSize()
$(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF")
# Click a .js-select-on-focus field, select the contents
......@@ -256,35 +257,14 @@ $ ->
$('.right-sidebar')
.hasClass('right-sidebar-collapsed'), { path: '/' })
bootstrapBreakpoint = undefined;
checkBootstrapBreakpoints = ->
if $('.device-xs').is(':visible')
bootstrapBreakpoint = "xs"
else if $('.device-sm').is(':visible')
bootstrapBreakpoint = "sm"
else if $('.device-md').is(':visible')
bootstrapBreakpoint = "md"
else if $('.device-lg').is(':visible')
bootstrapBreakpoint = "lg"
setBootstrapBreakpoints = ->
if $('.device-xs').length
return
$("body")
.append('<div class="device-xs visible-xs"></div>'+
'<div class="device-sm visible-sm"></div>'+
'<div class="device-md visible-md"></div>'+
'<div class="device-lg visible-lg"></div>')
checkBootstrapBreakpoints()
fitSidebarForSize = ->
oldBootstrapBreakpoint = bootstrapBreakpoint
checkBootstrapBreakpoints()
bootstrapBreakpoint = bp.getBreakpointSize()
if bootstrapBreakpoint != oldBootstrapBreakpoint
$(document).trigger('breakpoint:change', [bootstrapBreakpoint])
checkInitialSidebarSize = ->
bootstrapBreakpoint = bp.getBreakpointSize()
if bootstrapBreakpoint is "xs" or "sm"
$(document).trigger('breakpoint:change', [bootstrapBreakpoint])
......@@ -293,6 +273,5 @@ $ ->
.on "resize", (e) ->
fitSidebarForSize()
setBootstrapBreakpoints()
checkInitialSidebarSize()
new Aside()
class @Breakpoints
instance = null;
class BreakpointInstance
BREAKPOINTS = ["xs", "sm", "md", "lg"]
constructor: ->
@setup()
setup: ->
allDeviceSelector = BREAKPOINTS.map (breakpoint) ->
".device-#{breakpoint}"
return if $(allDeviceSelector.join(",")).length
# Create all the elements
els = $.map BREAKPOINTS, (breakpoint) ->
"<div class='device-#{breakpoint} visible-#{breakpoint}'></div>"
$("body").append els.join('')
visibleDevice: ->
allDeviceSelector = BREAKPOINTS.map (breakpoint) ->
".device-#{breakpoint}"
$(allDeviceSelector.join(",")).filter(":visible")
getBreakpointSize: ->
$visibleDevice = @visibleDevice
# the page refreshed via turbolinks
if not $visibleDevice().length
@setup()
$visibleDevice = @visibleDevice()
return $visibleDevice.attr("class").split("visible-")[1]
@get: ->
return instance ?= new BreakpointInstance
$ =>
@bp = Breakpoints.get()
......@@ -251,7 +251,7 @@ class GitLabDropdown
# Toggle active class for the tick mark
el.toggleClass "is-active"
if value
if value?
if !field.length
# Create hidden input for form
input = "<input type='hidden' name='#{fieldName}' />"
......
......@@ -17,52 +17,14 @@ class @Profile
$('.update-notifications').on 'ajax:complete', ->
$(this).find('.btn-save').enable()
# Avatar management
$avatarInput = $('.js-user-avatar-input')
$filename = $('.js-avatar-filename')
$modalCrop = $('.modal-profile-crop')
$modalCropImg = $('.modal-profile-crop-image')
$('.js-choose-user-avatar-button').on "click", ->
$form = $(this).closest("form")
$form.find(".js-user-avatar-input").click()
$modalCrop.on 'shown.bs.modal', ->
setTimeout ( -> # The cropper must be asynchronously initialized
$modalCropImg.cropper
aspectRatio: 1
modal: false
scalable: false
rotatable: false
zoomable: false
crop: (event) ->
['x', 'y'].forEach (key) ->
$("#user_avatar_crop_#{key}").val(Math.floor(event[key]))
$("#user_avatar_crop_size").val(Math.floor(event.width))
), 0
$modalCrop.on 'hidden.bs.modal', ->
$modalCropImg.attr('src', '').cropper('destroy')
$avatarInput.val('')
$filename.text($filename.data('label'))
$('.js-upload-user-avatar').on 'click', ->
$('.edit-user').submit()
$('.js-choose-user-avatar-button').bind "click", ->
form = $(this).closest("form")
form.find(".js-user-avatar-input").click()
$avatarInput.on "change", ->
$('.js-user-avatar-input').bind "change", ->
form = $(this).closest("form")
filename = $(this).val().replace(/^.*[\\\/]/, '')
$filename.data('label', $filename.text()).text(filename)
reader = new FileReader
reader.onload = (event) ->
$modalCrop.modal('show')
$modalCropImg.attr('src', event.target.result)
fileData = reader.readAsDataURL(this.files[0])
form.find(".js-avatar-filename").text(filename)
$ ->
# Extract the SSH Key title from its comment
......
$(document).on("click", '.toggle-nav-collapse', (e) ->
e.preventDefault()
collapsed = 'page-sidebar-collapsed'
expanded = 'page-sidebar-expanded'
collapsed = 'page-sidebar-collapsed'
expanded = 'page-sidebar-expanded'
toggleSidebar = ->
$('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}")
$('header').toggleClass("header-collapsed header-expanded")
$('.sidebar-wrapper').toggleClass("sidebar-collapsed sidebar-expanded")
......@@ -14,4 +13,15 @@ $(document).on("click", '.toggle-nav-collapse', (e) ->
niceScrollBars.updateScrollBar();
), 300
$(document).on("click", '.toggle-nav-collapse', (e) ->
e.preventDefault()
toggleSidebar()
)
$ ->
size = bp.getBreakpointSize()
if size is "xs" or size is "sm"
if $('.page-with-sidebar').hasClass(expanded)
toggleSidebar()
class @Subscription
constructor: (url) ->
$(".subscribe-button").unbind("click").click (event)=>
btn = $(event.currentTarget)
action = btn.find("span").text()
current_status = $(".subscription-status").attr("data-status")
btn.prop("disabled", true)
$.post url, =>
btn.prop("disabled", false)
status = if current_status == "subscribed" then "unsubscribed" else "subscribed"
$(".subscription-status").attr("data-status", status)
action = if status == "subscribed" then "Unsubscribe" else "Subscribe"
btn.find("span").text(action)
$(".subscription-status>div").toggleClass("hidden")
constructor: (container) ->
$container = $(container)
@url = $container.attr('data-url')
@subscribe_button = $container.find('.subscribe-button')
@subscription_status = $container.find('.subscription-status')
@subscribe_button.unbind('click').click(@toggleSubscription)
toggleSubscription: (event) =>
btn = $(event.currentTarget)
action = btn.find('span').text()
current_status = @subscription_status.attr('data-status')
btn.prop('disabled', true)
$.post @url, =>
btn.prop('disabled', false)
status = if current_status == 'subscribed' then 'unsubscribed' else 'subscribed'
@subscription_status.attr('data-status', status)
action = if status == 'subscribed' then 'Unsubscribe' else 'Subscribe'
btn.find('span').text(action)
@subscription_status.find('>div').toggleClass('hidden')
......@@ -9,7 +9,6 @@
*= require_self
*= require dropzone/basic
*= require cal-heatmap
*= require cropper.css
*/
/*
......
......@@ -120,6 +120,10 @@
.cover-desc {
padding: 0 $gl-padding 3px;
color: $gl-text-color;
&.username:last-child {
padding-bottom: $gl-padding;
}
}
.cover-controls {
......
......@@ -141,22 +141,18 @@ header {
margin-left: $sidebar_collapsed_width;
}
@media (max-width: $screen-md-max) {
.header-collapsed {
margin-left: $sidebar_collapsed_width;
}
.header-expanded {
margin-left: $sidebar_width;
}
}
.header-collapsed {
margin-left: $sidebar_collapsed_width;
@media(min-width: $screen-md-max) {
.header-collapsed {
@media (min-width: $screen-md-min) {
@include collapsed-header;
}
}
.header-expanded {
margin-left: $sidebar_collapsed_width;
.header-expanded {
@media (min-width: $screen-md-min) {
margin-left: $sidebar_width;
}
}
......
......@@ -41,12 +41,6 @@
transition: $transition;
}
@mixin transform($transform) {
-webkit-transform: $transform;
-ms-transform: $transform;
transform: $transform;
}
/**
* Prefilled mixins
* Mixins with fixed values
......
......@@ -34,12 +34,12 @@
@media (min-width: $screen-sm-min) {
padding-right: $gutter_width;
}
}
}
.sidebar-wrapper {
z-index: 99;
z-index: 999;
background: $background-color;
}
......@@ -203,7 +203,11 @@
}
@mixin expanded-sidebar {
padding-left: $sidebar_width;
padding-left: $sidebar_collapsed_width;
@media (min-width: $screen-md-min) {
padding-left: $sidebar_width;
}
&.right-sidebar-collapsed {
/* Extra small devices (phones, less than 768px) */
......
......@@ -103,6 +103,10 @@ $border-red-dark: #CA264F;
$help-well-bg: #FAFAFA;
$help-well-border: #E5E5E5;
$warning-message-bg: #FBF2D9;
$warning-message-color: #9E8E60;
$warning-message-border: #F0E2BB;
/* header */
$light-grey-header: #faf9f9;
......
......@@ -41,3 +41,7 @@
.color-label {
padding: 3px 4px;
}
.label-subscription {
display: inline-block;
}
......@@ -109,42 +109,6 @@
}
}
.modal-profile-crop {
.modal-dialog {
width: 500px;
}
.modal-body {
p {
display: table;
margin: auto;
overflow: hidden;
}
img {
display: block;
max-width: 400px;
max-height: 400px;
}
.cropper-bg {
background: none;
}
.cropper-crop-box {
box-sizing: content-box;
border: 999px solid transparentize(#ccc, 0.5);
@include transform(translate(-999px, -999px));
}
}
}
@media (max-width: 520px) {
.modal-profile-crop .modal-dialog {
width: auto;
}
}
.key-list-item {
.key-list-item-info {
@media (min-width: $screen-sm-min) {
......@@ -215,3 +179,21 @@
color: $provider-btn-not-active-color;
}
}
.profile-settings-message {
line-height: 32px;
color: $warning-message-color;
background-color: $warning-message-bg;
border: 1px solid $warning-message-border;
border-radius: $border-radius-base;
}
.oauth-applications {
form {
display: inline-block;
}
.last-heading {
width: 105px;
}
}
module ToggleSubscriptionAction
extend ActiveSupport::Concern
def toggle_subscription
return unless current_user
subscribable_resource.toggle_subscription(current_user)
render nothing: true
end
private
def subscribable_resource
raise NotImplementedError
end
end
......@@ -8,7 +8,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
layout 'profile'
def index
head :forbidden and return
set_index_vars
end
def create
......@@ -20,18 +20,11 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
redirect_to oauth_application_url(@application)
else
render :new
set_index_vars
render :index
end
end
def destroy
if @application.destroy
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :destroy])
end
redirect_to applications_profile_url
end
private
def verify_user_oauth_applications_enabled
......@@ -40,6 +33,17 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
redirect_to applications_profile_url
end
def set_index_vars
@applications = current_user.oauth_applications
@authorized_tokens = current_user.oauth_authorized_tokens
@authorized_anonymous_tokens = @authorized_tokens.reject(&:application)
@authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?)
# Don't overwrite a value possibly set by `create`
@application ||= Doorkeeper::Application.new
end
# Override Doorkeeper to scope to the current user
def set_application
@application = current_user.oauth_applications.find(params[:id])
end
......
......@@ -8,13 +8,6 @@ class ProfilesController < Profiles::ApplicationController
def show
end
def applications
@applications = current_user.oauth_applications
@authorized_tokens = current_user.oauth_authorized_tokens
@authorized_anonymous_tokens = @authorized_tokens.reject(&:application)
@authorized_apps = @authorized_tokens.map(&:application).uniq - [nil]
end
def update
user_params.except!(:email) if @user.ldap_user?
......@@ -65,9 +58,6 @@ class ProfilesController < Profiles::ApplicationController
def user_params
params.require(:user).permit(
:avatar_crop_x,
:avatar_crop_y,
:avatar_crop_size,
:avatar,
:bio,
:email,
......
class Projects::IssuesController < Projects::ApplicationController
include ToggleSubscriptionAction
before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :toggle_subscription]
before_action :issue, only: [:edit, :update, :show]
# Allow read any issue
before_action :authorize_read_issue!
......@@ -110,12 +112,6 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_back_or_default(default: { action: 'index' }, options: { notice: "#{result[:count]} issues updated" })
end
def toggle_subscription
@issue.toggle_subscription(current_user)
render nothing: true
end
def closed_by_merge_requests
@closed_by_merge_requests ||= @issue.closed_by_merge_requests(current_user)
end
......@@ -129,6 +125,7 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_old
end
end
alias_method :subscribable_resource, :issue
def authorize_update_issue!
return render_404 unless can?(current_user, :update_issue, @issue)
......
class Projects::LabelsController < Projects::ApplicationController
include ToggleSubscriptionAction
before_action :module_enabled
before_action :label, only: [:edit, :update, :destroy]
before_action :authorize_read_label!
before_action :authorize_admin_labels!, except: [:index]
before_action :authorize_admin_labels!, only: [
:new, :create, :edit, :update, :generate, :destroy
]
respond_to :js, :html
......@@ -73,8 +77,9 @@ class Projects::LabelsController < Projects::ApplicationController
end
def label
@label = @project.labels.find(params[:id])
@label ||= @project.labels.find(params[:id])
end
alias_method :subscribable_resource, :label
def authorize_admin_labels!
return render_404 unless can?(current_user, :admin_label, @project)
......
class Projects::MergeRequestsController < Projects::ApplicationController
include ToggleSubscriptionAction
include DiffHelper
before_action :module_enabled
before_action :merge_request, only: [
:edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check,
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds
:ci_status, :cancel_merge_when_build_succeeds
]
before_action :closes_issues, only: [:edit, :update, :show, :diffs, :commits, :builds]
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds]
......@@ -233,12 +234,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
render json: response
end
def toggle_subscription
@merge_request.toggle_subscription(current_user)
render nothing: true
end
protected
def selected_target_project
......@@ -252,6 +247,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
def merge_request
@merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
end
alias_method :subscribable_resource, :merge_request
def closes_issues
@closes_issues ||= @merge_request.closes_issues
......
......@@ -172,10 +172,15 @@ class ProjectsController < ApplicationController
def housekeeping
::Projects::HousekeepingService.new(@project).execute
respond_to do |format|
flash[:notice] = "Housekeeping successfully started."
format.html { redirect_to project_path(@project) }
end
redirect_to(
project_path(@project),
notice: "Housekeeping successfully started"
)
rescue ::Projects::HousekeepingService::LeaseTaken => ex
redirect_to(
edit_project_path(@project),
alert: ex.to_s
)
end
def toggle_star
......
......@@ -12,9 +12,13 @@ module CiStatusHelper
ci_label_for_status(ci_commit.status)
end
def ci_status_with_icon(status)
content_tag :span, class: "ci-status ci-#{status}" do
ci_icon_for_status(status) + '&nbsp;'.html_safe + ci_label_for_status(status)
def ci_status_with_icon(status, target = nil)
content = ci_icon_for_status(status) + '&nbsp;'.html_safe + ci_label_for_status(status)
klass = "ci-status ci-#{status}"
if target
link_to content, target, class: klass
else
content_tag :span, content, class: klass
end
end
......
......@@ -3,7 +3,7 @@ module EventsHelper
author = event.author
if author
link_to author.name, user_path(author.username)
link_to author.name, user_path(author.username), title: h(author.name)
else
event.author_name
end
......@@ -159,7 +159,7 @@ module EventsHelper
link_to(
namespace_project_commit_path(event.project.namespace, event.project,
event.note_commit_id,
anchor: dom_id(event.target)),
anchor: dom_id(event.target), title: h(event.target_title)),
class: "commit_short_id"
) do
"#{event.note_target_type} #{event.note_short_commit_id}"
......@@ -167,7 +167,7 @@ module EventsHelper
elsif event.note_project_snippet?
link_to(namespace_project_snippet_path(event.project.namespace,
event.project,
event.note_target)) do
event.note_target), title: h(event.project.name)) do
"#{event.note_target_type} #{truncate event.note_target.to_reference}"
end
else
......
......@@ -31,7 +31,11 @@ module IssuablesHelper
end
def issuable_state_scope(issuable)
issuable.open? ? :opened : :closed
if issuable.respond_to?(:merged?) && issuable.merged?
:merged
else
issuable.open? ? :opened : :closed
end
end
end
......@@ -124,6 +124,14 @@ module LabelsHelper
options_from_collection_for_select(grouped_labels, 'name', 'title', params[:label_name])
end
def label_subscription_status(label)
label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed'
end
def label_subscription_toggle_button_text(label)
label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe'
end
# Required for Banzai::Filter::LabelReferenceFilter
module_function :render_colored_label, :render_colored_cross_project_label,
:text_color_for_bg, :escape_once
......
......@@ -8,7 +8,7 @@ module ProjectsHelper
end
def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project] do
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
if project.namespace
......
......@@ -16,7 +16,7 @@ module TodosHelper
def todo_target_link(todo)
target = todo.target_type.titleize.downcase
link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo)
link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: h(todo.target.title) }
end
def todo_target_path(todo)
......
......@@ -16,7 +16,15 @@ module Emails
def closed_issue_email(recipient_id, issue_id, updated_by_user_id)
setup_issue_mail(issue_id, recipient_id)
@updated_by = User.find updated_by_user_id
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id)
setup_issue_mail(issue_id, recipient_id)
@label_names = label_names
@labels_url = namespace_project_labels_url(@project.namespace, @project)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
......@@ -24,20 +32,12 @@ module Emails
setup_issue_mail(issue_id, recipient_id)
@issue_status = status
@updated_by = User.find updated_by_user_id
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
private
def issue_thread_options(sender_id, recipient_id)
{
from: sender(sender_id),
to: recipient(recipient_id),
subject: subject("#{@issue.title} (##{@issue.iid})")
}
end
def setup_issue_mail(issue_id, recipient_id)
@issue = Issue.find(issue_id)
@project = @issue.project
......@@ -45,5 +45,13 @@ module Emails
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
def issue_thread_options(sender_id, recipient_id)
{
from: sender(sender_id),
to: recipient(recipient_id),
subject: subject("#{@issue.title} (##{@issue.iid})")
}
end
end
end
......@@ -3,50 +3,43 @@ module Emails
def new_merge_request_email(recipient_id, merge_request_id)
setup_merge_request_mail(merge_request_id, recipient_id)
mail_new_thread(@merge_request,
from: sender(@merge_request.author_id),
to: recipient(recipient_id),
subject: subject("#{@merge_request.title} (##{@merge_request.iid})"))
mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id))
end
def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
@previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
mail_answer_thread(@merge_request,
from: sender(updated_by_user_id),
to: recipient(recipient_id),
subject: subject("#{@merge_request.title} (##{@merge_request.iid})"))
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
@label_names = label_names
@labels_url = namespace_project_labels_url(@project.namespace, @project)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find updated_by_user_id
mail_answer_thread(@merge_request,
from: sender(updated_by_user_id),
to: recipient(recipient_id),
subject: subject("#{@merge_request.title} (##{@merge_request.iid})"))
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
mail_answer_thread(@merge_request,
from: sender(updated_by_user_id),
to: recipient(recipient_id),
subject: subject("#{@merge_request.title} (##{@merge_request.iid})"))
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
@mr_status = status
@updated_by = User.find updated_by_user_id
mail_answer_thread(@merge_request,
from: sender(updated_by_user_id),
to: recipient(recipient_id),
subject: subject("#{@merge_request.title} (##{@merge_request.iid})"))
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
private
......@@ -54,11 +47,17 @@ module Emails
def setup_merge_request_mail(merge_request_id, recipient_id)
@merge_request = MergeRequest.find(merge_request_id)
@project = @merge_request.project
@target_url = namespace_project_merge_request_url(@project.namespace,
@project,
@merge_request)
@target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request)
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end
def merge_request_thread_options(sender_id, recipient_id)
{
from: sender(sender_id),
to: recipient(recipient_id),
subject: subject("#{@merge_request.title} (##{@merge_request.iid})")
}
end
end
end
......@@ -14,7 +14,10 @@ module Emails
end
def new_ssh_key_email(key_id)
@key = Key.find(key_id)
@key = Key.find_by_id(key_id)
return unless @key
@current_user = @user = @key.user
@target_url = user_url(@user)
mail(to: @user.notification_email, subject: subject("SSH key was added to your account"))
......
......@@ -37,8 +37,6 @@
module Ci
class Build < CommitStatus
include Gitlab::Application.routes.url_helpers
LAZY_ATTRIBUTES = ['trace']
belongs_to :runner, class_name: 'Ci::Runner'
......@@ -128,7 +126,7 @@ module Ci
end
def retried?
!self.commit.latest_builds_for_ref(self.ref).include?(self)
!self.commit.latest_statuses_for_ref(self.ref).include?(self)
end
def depends_on_builds
......@@ -309,22 +307,6 @@ module Ci
project.valid_runners_token? token
end
def target_url
namespace_project_build_url(project.namespace, project, self)
end
def cancel_url
if active?
cancel_namespace_project_build_path(project.namespace, project, self)
end
end
def retry_url
if retryable?
retry_namespace_project_build_path(project.namespace, project, self)
end
end
def can_be_served?(runner)
(tag_list - runner.tag_list).empty?
end
......@@ -333,7 +315,7 @@ module Ci
project.any_runners? { |runner| runner.active? && runner.online? && can_be_served?(runner) }
end
def show_warning?
def stuck?
pending? && !any_runners_online?
end
......@@ -348,18 +330,6 @@ module Ci
artifacts_file.exists?
end
def artifacts_download_url
if artifacts?
download_namespace_project_build_artifacts_path(project.namespace, project, self)
end
end
def artifacts_browse_url
if artifacts_metadata?
browse_namespace_project_build_artifacts_path(project.namespace, project, self)
end
end
def artifacts_metadata?
artifacts? && artifacts_metadata.exists?
end
......
......@@ -25,8 +25,6 @@ module Ci
has_many :builds, class_name: 'Ci::Build'
has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest'
scope :ordered, -> { order('CASE WHEN ci_commits.committed_at IS NULL THEN 0 ELSE 1 END', :committed_at, :id) }
validates_presence_of :sha
validate :valid_commit_sha
......@@ -42,16 +40,6 @@ module Ci
project.id
end
def last_build
builds.order(:id).last
end
def retry
latest_builds.each do |build|
Ci::Build.retry(build)
end
end
def valid_commit_sha
if self.sha == Gitlab::Git::BLANK_SHA
self.errors.add(:sha, " cant be 00000000 (branch removal)")
......@@ -121,12 +109,14 @@ module Ci
@latest_statuses ||= statuses.latest.to_a
end
def latest_builds
@latest_builds ||= builds.latest.to_a
def latest_statuses_for_ref(ref)
latest_statuses.select { |status| status.ref == ref }
end
def latest_builds_for_ref(ref)
latest_builds.select { |build| build.ref == ref }
def matrix_builds(build = nil)
matrix_builds = builds.latest.ordered
matrix_builds = matrix_builds.similar(build) if build
matrix_builds.to_a
end
def retried
......@@ -170,7 +160,7 @@ module Ci
end
def duration
duration_array = latest_statuses.map(&:duration).compact
duration_array = statuses.map(&:duration).compact
duration_array.reduce(:+).to_i
end
......@@ -183,16 +173,12 @@ module Ci
end
def coverage
coverage_array = latest_builds.map(&:coverage).compact
coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
'%.2f' % (coverage_array.reduce(:+) / coverage_array.size)
end
end
def matrix_for_ref?(ref)
latest_builds_for_ref(ref).size > 1
end
def config_processor
return nil unless ci_yaml_file
@config_processor ||= Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace)
......@@ -218,10 +204,6 @@ module Ci
git_commit_message =~ /(\[ci skip\])/ if git_commit_message
end
def update_committed!
update!(committed_at: DateTime.now)
end
private
def save_yaml_error(error)
......
......@@ -125,23 +125,7 @@ class CommitStatus < ActiveRecord::Base
end
end
def cancel_url
nil
end
def retry_url
nil
end
def show_warning?
def stuck?
false
end
def artifacts_download_url
nil
end
def artifacts_browse_url
nil
end
end
......@@ -8,6 +8,7 @@ module Issuable
extend ActiveSupport::Concern
include Participable
include Mentionable
include Subscribable
include StripAttribute
included do
......@@ -18,7 +19,6 @@ module Issuable
has_many :notes, as: :noteable, dependent: :destroy
has_many :label_links, as: :target, dependent: :destroy
has_many :labels, through: :label_links
has_many :subscriptions, dependent: :destroy, as: :subscribable
validates :author, presence: true
validates :title, presence: true, length: { within: 0..255 }
......@@ -149,28 +149,10 @@ module Issuable
notes.awards.where(note: "thumbsup").count
end
def subscribed?(user)
subscription = subscriptions.find_by_user_id(user.id)
if subscription
return subscription.subscribed
end
def subscribed_without_subscriptions?(user)
participants(user).include?(user)
end
def toggle_subscription(user)
subscriptions.
find_or_initialize_by(user_id: user.id).
update(subscribed: !subscribed?(user))
end
def unsubscribe(user)
subscriptions.
find_or_initialize_by(user_id: user.id).
update(subscribed: false)
end
def to_hook_data(user)
hook_data = {
object_kind: self.class.name.underscore,
......
# == Subscribable concern
#
# Users can subscribe to these models.
#
# Used by Issue, MergeRequest, Label
#
module Subscribable
extend ActiveSupport::Concern
included do
has_many :subscriptions, dependent: :destroy, as: :subscribable
end
def subscribed?(user)
if subscription = subscriptions.find_by_user_id(user.id)
subscription.subscribed
else
subscribed_without_subscriptions?(user)
end
end
# Override this method to define custom logic to consider a subscribable as
# subscribed without an explicit subscription record.
def subscribed_without_subscriptions?(user)
false
end
def subscribers
subscriptions.where(subscribed: true).map(&:user)
end
def toggle_subscription(user)
subscriptions.
find_or_initialize_by(user_id: user.id).
update(subscribed: !subscribed?(user))
end
def unsubscribe(user)
subscriptions.
find_or_initialize_by(user_id: user.id).
update(subscribed: false)
end
end
......@@ -16,6 +16,7 @@
require 'digest/md5'
class Key < ActiveRecord::Base
include AfterCommitQueue
include Sortable
belongs_to :user
......@@ -62,7 +63,7 @@ class Key < ActiveRecord::Base
end
def notify_user
NotificationService.new.new_key(self)
run_after_commit { NotificationService.new.new_key(self) }
end
def post_create_hook
......
......@@ -14,6 +14,8 @@
class Label < ActiveRecord::Base
include Referable
include Subscribable
# Represents a "No Label" state used for filtering Issues and Merge
# Requests that have no label assigned.
LabelStruct = Struct.new(:title, :name)
......
......@@ -280,7 +280,14 @@ class Project < ActiveRecord::Base
or(ptable[:description].matches(pattern))
)
# We explicitly remove any eager loading clauses as they're:
#
# 1. Not needed by this query
# 2. Combined with .joins(:namespace) lead to all columns from the
# projects & namespaces tables being selected, leading to a SQL error
# due to the columns of all UNION'd queries no longer being the same.
namespaces = select(:id).
except(:includes).
joins(:namespace).
where(ntable[:name].matches(pattern))
......@@ -502,6 +509,7 @@ class Project < ActiveRecord::Base
end
def external_issue_tracker
return @external_issue_tracker if defined?(@external_issue_tracker)
@external_issue_tracker ||=
services.issue_trackers.active.without_defaults.first
end
......
......@@ -15,7 +15,7 @@ class Subscription < ActiveRecord::Base
belongs_to :user
belongs_to :subscribable, polymorphic: true
validates :user_id,
validates :user_id,
uniqueness: { scope: [:subscribable_id, :subscribable_type] },
presence: true
end
......@@ -100,9 +100,6 @@ class User < ActiveRecord::Base
# Virtual attribute for authenticating by either username or email
attr_accessor :login
# Virtual attributes to define avatar cropping
attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size
#
# Relations
#
......@@ -168,11 +165,6 @@ class User < ActiveRecord::Base
validate :owns_public_email, if: ->(user) { user.public_email_changed? }
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
validates :avatar_crop_x, :avatar_crop_y, :avatar_crop_size,
numericality: { only_integer: true },
presence: true,
if: ->(user) { user.avatar? && user.avatar_changed? }
before_validation :generate_password, on: :create
before_validation :restricted_signup_domains, on: :create
before_validation :sanitize_attrs
......
......@@ -3,7 +3,7 @@ module Ci
def execute(project, opts)
sha = opts[:sha] || ref_sha(project, opts[:ref])
commit = project.ci_commits.ordered.find_by(sha: sha)
commit = project.ci_commits.find_by(sha: sha)
image_name = image_for_commit(commit)
image_path = Rails.root.join('public/ci', image_name)
......
......@@ -33,7 +33,6 @@ class CreateCommitBuildsService
unless commit.skip_ci?
# Create builds for commit
tag = Gitlab::Git.tag_ref?(origin_ref)
commit.update_committed!
commit.create_builds(ref, tag, user)
end
......
......@@ -49,6 +49,8 @@ class GitPushService < BaseService
# Update merge requests that may be affected by this push. A new branch
# could cause the last commit of a merge request to change.
update_merge_requests
perform_housekeeping
end
def update_main_language
......@@ -73,6 +75,13 @@ class GitPushService < BaseService
ProjectCacheWorker.perform_async(@project.id)
end
def perform_housekeeping
housekeeping = Projects::HousekeepingService.new(@project)
housekeeping.increment!
housekeeping.execute if housekeeping.needed?
rescue Projects::HousekeepingService::LeaseTaken
end
def process_default_branch
@push_commits = project.repository.commits(params[:newrev])
......@@ -80,7 +89,7 @@ class GitPushService < BaseService
project.change_head(branch_name)
# Set protection on the default branch if configured
if (current_application_settings.default_branch_protection != PROTECTION_NONE)
if current_application_settings.default_branch_protection != PROTECTION_NONE
developers_can_push = current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? true : false
@project.protected_branches.create({ name: @project.default_branch, developers_can_push: developers_can_push })
end
......
......@@ -11,7 +11,10 @@ class IssuableBaseService < BaseService
issuable, issuable.project, current_user, issuable.milestone)
end
def create_labels_note(issuable, added_labels, removed_labels)
def create_labels_note(issuable, old_labels)
added_labels = issuable.labels - old_labels
removed_labels = old_labels - issuable.labels
SystemNoteService.change_label(
issuable, issuable.project, current_user, added_labels, removed_labels)
end
......@@ -71,20 +74,19 @@ class IssuableBaseService < BaseService
end
end
def has_changes?(issuable, options = {})
def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr|
issuable.previous_changes.include?(attr.to_s)
end
old_labels = options[:old_labels]
labels_changed = old_labels && issuable.labels != old_labels
labels_changed = issuable.labels != old_labels
attrs_changed || labels_changed
end
def handle_common_system_notes(issuable, options = {})
def handle_common_system_notes(issuable, old_labels: [])
if issuable.previous_changes.include?('title')
create_title_change_note(issuable, issuable.previous_changes['title'].first)
end
......@@ -93,9 +95,6 @@ class IssuableBaseService < BaseService
create_task_status_note(issuable)
end
old_labels = options[:old_labels]
if old_labels && (issuable.labels != old_labels)
create_labels_note(issuable, issuable.labels - old_labels, old_labels - issuable.labels)
end
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
end
......@@ -4,8 +4,8 @@ module Issues
update(issue)
end
def handle_changes(issue, options = {})
if has_changes?(issue, options)
def handle_changes(issue, old_labels: [])
if has_changes?(issue, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(issue, current_user)
end
......@@ -23,6 +23,11 @@ module Issues
notification_service.reassigned_issue(issue, current_user)
todo_service.reassigned_issue(issue, current_user)
end
added_labels = issue.labels - old_labels
if added_labels.present?
notification_service.relabeled_issue(issue, added_labels, current_user)
end
end
def reopen_service
......
......@@ -14,8 +14,8 @@ module MergeRequests
update(merge_request)
end
def handle_changes(merge_request, options = {})
if has_changes?(merge_request, options)
def handle_changes(merge_request, old_labels: [])
if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
end
......@@ -44,6 +44,15 @@ module MergeRequests
merge_request.previous_changes.include?('source_branch')
merge_request.mark_as_unchecked
end
added_labels = merge_request.labels - old_labels
if added_labels.present?
notification_service.relabeled_merge_request(
merge_request,
added_labels,
current_user
)
end
end
def reopen_service
......
......@@ -24,16 +24,17 @@ class NotificationService
end
end
# When create an issue we should send next emails:
# When create an issue we should send an email to:
#
# * issue assignee if their notification level is not Disabled
# * project team members with notification level higher then Participating
# * watchers of the issue's labels
#
def new_issue(issue, current_user)
new_resource_email(issue, issue.project, 'new_issue_email')
end
# When we close an issue we should send next emails:
# When we close an issue we should send an email to:
#
# * issue author if their notification level is not Disabled
# * issue assignee if their notification level is not Disabled
......@@ -43,7 +44,7 @@ class NotificationService
close_resource_email(issue, issue.project, current_user, 'closed_issue_email')
end
# When we reassign an issue we should send next emails:
# When we reassign an issue we should send an email to:
#
# * issue old assignee if their notification level is not Disabled
# * issue new assignee if their notification level is not Disabled
......@@ -52,16 +53,25 @@ class NotificationService
reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email')
end
# When we add labels to an issue we should send an email to:
#
# * watchers of the issue's labels
#
def relabeled_issue(issue, added_labels, current_user)
relabeled_resource_email(issue, added_labels, current_user, 'relabeled_issue_email')
end
# When create a merge request we should send next emails:
# When create a merge request we should send an email to:
#
# * mr assignee if their notification level is not Disabled
# * project team members with notification level higher then Participating
# * watchers of the mr's labels
#
def new_merge_request(merge_request, current_user)
new_resource_email(merge_request, merge_request.target_project, 'new_merge_request_email')
end
# When we reassign a merge_request we should send next emails:
# When we reassign a merge_request we should send an email to:
#
# * merge_request old assignee if their notification level is not Disabled
# * merge_request assignee if their notification level is not Disabled
......@@ -70,6 +80,14 @@ class NotificationService
reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email')
end
# When we add labels to a merge request we should send an email to:
#
# * watchers of the mr's labels
#
def relabeled_merge_request(merge_request, added_labels, current_user)
relabeled_resource_email(merge_request, added_labels, current_user, 'relabeled_merge_request_email')
end
def close_mr(merge_request, current_user)
close_resource_email(merge_request, merge_request.target_project, current_user, 'closed_merge_request_email')
end
......@@ -91,7 +109,8 @@ class NotificationService
reopen_resource_email(
merge_request,
merge_request.target_project,
current_user, 'merge_request_status_email',
current_user,
'merge_request_status_email',
'reopened'
)
end
......@@ -348,19 +367,23 @@ class NotificationService
end
def add_subscribed_users(recipients, target)
return recipients unless target.respond_to? :subscriptions
return recipients unless target.respond_to? :subscribers
subscriptions = target.subscriptions
recipients + target.subscribers
end
if subscriptions.any?
recipients + subscriptions.where(subscribed: true).map(&:user)
else
recipients
def add_labels_subscribers(recipients, target, labels: nil)
return recipients unless target.respond_to? :labels
(labels || target.labels).each do |label|
recipients += label.subscribers
end
recipients
end
def new_resource_email(target, project, method)
recipients = build_recipients(target, project, target.author)
recipients = build_recipients(target, project, target.author, action: :new)
recipients.each do |recipient|
mailer.send(method, recipient.id, target.id).deliver_later
......@@ -392,6 +415,15 @@ class NotificationService
end
end
def relabeled_resource_email(target, labels, current_user, method)
recipients = build_relabeled_recipients(target, current_user, labels: labels)
label_names = labels.map(&:name)
recipients.each do |recipient|
mailer.send(method, recipient.id, target.id, label_names, current_user.id).deliver_later
end
end
def reopen_resource_email(target, project, current_user, method, status)
recipients = build_recipients(target, project, current_user)
......@@ -416,6 +448,11 @@ class NotificationService
recipients = reject_muted_users(recipients, project)
recipients = add_subscribed_users(recipients, target)
if action == :new
recipients = add_labels_subscribers(recipients, target)
end
recipients = reject_unsubscribed_users(recipients, target)
recipients.delete(current_user)
......@@ -423,6 +460,13 @@ class NotificationService
recipients.uniq
end
def build_relabeled_recipients(target, current_user, labels:)
recipients = add_labels_subscribers([], target, labels: labels)
recipients = reject_unsubscribed_users(recipients, target)
recipients.delete(current_user)
recipients.uniq
end
def mailer
Notify
end
......
......@@ -9,12 +9,39 @@ module Projects
class HousekeepingService < BaseService
include Gitlab::ShellAdapter
LEASE_TIMEOUT = 3600
class LeaseTaken < StandardError
def to_s
"Somebody already triggered housekeeping for this project in the past #{LEASE_TIMEOUT / 60} minutes"
end
end
def initialize(project)
@project = project
end
def execute
raise LeaseTaken if !try_obtain_lease
GitlabShellWorker.perform_async(:gc, @project.path_with_namespace)
ensure
@project.update_column(:pushes_since_gc, 0)
end
def needed?
@project.pushes_since_gc >= 10
end
def increment!
@project.increment!(:pushes_since_gc)
end
private
def try_obtain_lease
lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT)
lease.try_obtain
end
end
end
......@@ -2,22 +2,11 @@
class AvatarUploader < CarrierWave::Uploader::Base
include UploaderHelper
include CarrierWave::MiniMagick
storage :file
after :store, :reset_events_cache
process :cropper
def cropper
return unless model.respond_to?(:avatar_crop_size) && model.valid?
manipulate! do |img|
img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}"
end
end
def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end
......
......@@ -4,13 +4,13 @@
= ci_status_with_icon(build.status)
%td.build-link
- if can?(current_user, :read_build, project) && build.target_url
= link_to build.target_url do
- if can?(current_user, :read_build, build.project)
= link_to namespace_project_build_url(build.project.namespace, build.project, build) do
%strong Build ##{build.id}
- else
%strong Build ##{build.id}
- if build.show_warning?
- if build.stuck?
%i.fa.fa-warning.text-warning
%td
......@@ -18,11 +18,11 @@
= link_to project.name_with_namespace, admin_namespace_project_path(project.namespace, project), class: "monospace"
%td
= link_to build.short_sha, namespace_project_commit_path(project.namespace, project, build.sha), class: "monospace"
= link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace"
%td
- if build.ref
= link_to build.ref, namespace_project_commits_path(project.namespace, project, build.ref)
= link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref)
- else
.light none
......@@ -61,13 +61,12 @@
%td
.pull-right
- if can?(current_user, :read_build, project) && build.artifacts?
= link_to build.artifacts_download_url, title: 'Download artifacts' do
= link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do
%i.fa.fa-download
- if can?(current_user, :update_build, build.project)
- if build.active?
- if build.cancel_url
= link_to build.cancel_url, method: :post, title: 'Cancel' do
%i.fa.fa-remove.cred
- elsif defined?(allow_retry) && allow_retry && build.retry_url
= link_to build.retry_url, method: :post, title: 'Retry' do
= link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do
%i.fa.fa-remove.cred
- elsif defined?(allow_retry) && allow_retry && build.retryable?
= link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do
%i.fa.fa-repeat
%tr.build
%td.status
= ci_status_with_icon(commit.status)
- if commit.running?
&middot;
= commit.stage
%td.build-link
= link_to ci_status_path(commit) do
%strong #{commit.short_sha}
%td.build-message
%span= truncate_first_line(commit.git_commit_message)
%td.build-branch
- unless @ref
%span
- commit.refs.each do |ref|
= link_to truncate(ref, length: 25), ci_project_path(@project, ref: ref)
%td.duration
- if commit.duration > 0
#{time_interval_in_words commit.duration}
%td.timestamp
- if commit.finished_at
%span #{time_ago_in_words commit.finished_at} ago
- if commit.coverage
%td.coverage
#{commit.coverage}%
- submit_btn_css ||= 'btn btn-link btn-remove btn-sm'
= form_tag oauth_application_path(application) do
%input{:name => "_method", :type => "hidden", :value => "delete"}/
= submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css
\ No newline at end of file
- if defined? small
= button_tag type: "submit", class: "btn btn-transparent", data: { confirm: "Are you sure?" } do
%span.sr-only
Destroy
= icon('trash')
- else
= submit_tag 'Destroy', data: { confirm: "Are you sure?" }, class: submit_btn_css
= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f|
= form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f|
- if application.errors.any?
.alert.alert-danger
%ul
......@@ -6,25 +6,20 @@
%li= msg
.form-group
= f.label :name, class: 'control-label'
.col-sm-10
= f.text_field :name, class: 'form-control', required: true
= f.label :name, class: 'label-light'
= f.text_field :name, class: 'form-control', required: true
.form-group
= f.label :redirect_uri, class: 'control-label'
.col-sm-10
= f.text_area :redirect_uri, class: 'form-control', required: true
= f.label :redirect_uri, class: 'label-light'
= f.text_area :redirect_uri, class: 'form-control', required: true
%span.help-block
Use one line per URI
- if Doorkeeper.configuration.native_redirect_uri
%span.help-block
Use one line per URI
- if Doorkeeper.configuration.native_redirect_uri
%span.help-block
Use
%code= Doorkeeper.configuration.native_redirect_uri
for local tests
Use
%code= Doorkeeper.configuration.native_redirect_uri
for local tests
.form-actions
= f.submit 'Submit', class: "btn btn-create"
= link_to "Cancel", applications_profile_path, class: "btn btn-cancel"
.prepend-top-default
= f.submit 'Save application', class: "btn btn-create"
- page_title "Applications"
%h3.page-title Your applications
%p= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success'
- header_title page_title, applications_profile_path
.table-holder
%table.table.table-striped
%thead
%tr
%th Name
%th Callback URL
%th
%th
%tbody
- @applications.each do |application|
%tr{:id => "application_#{application.id}"}
%td= link_to application.name, oauth_application_path(application)
%td= application.redirect_uri
%td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link'
%td= render 'delete_form', application: application
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
- if user_oauth_applications?
Manage applications that can use GitLab as an OAuth provider,
and applications that you've authorized to use your account.
- else
Manage applications that you've authorized to use your account.
.col-lg-9
- if user_oauth_applications?
%h5.prepend-top-0
Add new application
= render 'form', application: @application
%hr
- if user_oauth_applications?
.oauth-applications
%h5
Your applications (#{@applications.size})
- if @applications.any?
.table-responsive
%table.table
%thead
%tr
%th Name
%th Callback URL
%th Clients
%th.last-heading
%tbody
- @applications.each do |application|
%tr{id: "application_#{application.id}"}
%td= link_to application.name, oauth_application_path(application)
%td
- application.redirect_uri.split.each do |uri|
%div= uri
%td= application.access_tokens.count
%td
= link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do
%span.sr-only
Edit
= icon('pencil')
= render 'delete_form', application: application, small: true
- else
.profile-settings-message.text-center
You don't have any applications
.oauth-authorized-applications.prepend-top-20.append-bottom-default
- if user_oauth_applications?
%h5
Authorized applications (#{@authorized_tokens.size})
- if @authorized_tokens.any?
.table-responsive
%table.table.table-striped
%thead
%tr
%th Name
%th Authorized At
%th Scope
%th
%tbody
- @authorized_apps.each do |app|
- token = app.authorized_tokens.order('created_at desc').first
%tr{id: "application_#{app.id}"}
%td= app.name
%td= token.created_at
%td= token.scopes
%td= render 'delete_form', application: app
- @authorized_anonymous_tokens.each do |token|
%tr
%td
Anonymous
%div.help-block
%em Authorization was granted by entering your username and password in the application.
%td= token.created_at
%td= token.scopes
%td= render 'delete_form', token: token
- else
.profile-settings-message.text-center
You don't have any authorized applications
%li.commit
.commit-row-title
= link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: ''
= link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id])
&middot;
= markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line
......@@ -3,7 +3,7 @@
.event-last-push
.event-last-push-text
%span You pushed to
= link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do
= link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do
%strong= event.ref_name
%span at
%strong= link_to_project event.project
......
......@@ -5,7 +5,7 @@
%strong= event.ref_name
- else
%strong
= link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name)
= link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.target_title)
at
= link_to_project event.project
......
.top-area
.nav-controls
= form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
- if @projects.present?
= search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false
= render 'shared/projects/dropdown'
- if can? current_user, :create_projects, @group
= link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
= icon('plus')
New Project
= render 'shared/projects/list', projects: @projects, stars: false, skip_namespace: true
= render 'shared/projects/list', projects: projects, stars: false, skip_namespace: true
- if projects.present?
.panel.panel-default
.panel-heading
Projects shared with
%strong #{@group.name}
(#{projects.count})
%ul.well-list
- projects.each do |project|
%li.project-row
= link_to namespace_project_path(project.namespace, project), class: dom_class(project) do
%span.namespace-name
- if project.namespace
= project.namespace.human_name
\/
%span.project-name
= truncate(project.name, length: 25)
%span.arrow
%i.icon-angle-right
= render 'shared/projects/list', projects: projects, stars: false, skip_namespace: false
......@@ -27,24 +27,33 @@
.cover-desc.description
= markdown(@group.description, pipeline: :description)
%ul.nav-links
%li.active
= link_to "#projects", 'data-toggle' => 'tab' do
Projects
- if @shared_projects.present?
%li
= link_to "#shared", 'data-toggle' => 'tab' do
Shared Projects
- if can?(current_user, :read_group, @group)
%div{ class: container_class }
.top-area
%ul.nav-links
%li.active
= link_to "#projects", 'data-toggle' => 'tab' do
All Projects
- if @shared_projects.present?
%li
= link_to "#shared", 'data-toggle' => 'tab' do
Shared Projects
.nav-controls
= form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
= search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false
= render 'shared/projects/dropdown'
- if can? current_user, :create_projects, @group
= link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
= icon('plus')
New Project
.tab-content
.tab-pane.active#projects
= render "projects", projects: @projects
.tab-pane#shared
= render "shared_projects", projects: @shared_projects
- if @shared_projects.present?
.tab-pane#shared
= render "shared_projects", projects: @shared_projects
- else
%p.nav-links.no-top
......
......@@ -17,7 +17,7 @@
= icon('gear fw')
%span
Account
= nav_link(path: ['profiles#applications', 'applications#edit', 'applications#show', 'applications#new', 'applications#create']) do
= nav_link(controller: 'oauth/applications') do
= link_to applications_profile_path, title: 'Applications' do
= icon('cloud fw')
%span
......
......@@ -42,12 +42,15 @@
- else
#{link_to "View it on GitLab", @target_url}.
%br
-# Don't link the host is the line below, one link in the email is easier to quickly click than two.
-# Don't link the host in the line below, one link in the email is easier to quickly click than two.
You're receiving this email because of your account on #{Gitlab.config.gitlab.host}.
If you'd like to receive fewer emails, you can
- if @sent_notification && @sent_notification.unsubscribable?
= link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification)
from this thread or
adjust your notification settings.
- if @labels_url
adjust your #{link_to 'label subscriptions', @labels_url}.
- else
- if @sent_notification && @sent_notification.unsubscribable?
= link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification)
from this thread or
adjust your notification settings.
= email_action @target_url
Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %>
<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, {only_path: false}]) %>
<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %>
Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %>
%p
#{'Label'.pluralize(@label_names.size)} added:
%em= @label_names.to_sentence
<%= 'Label'.pluralize(@label_names.size) %> added: <%= @label_names.to_sentence %>
<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %>
= render 'relabeled_issuable_email', issuable: @issue
<%= render 'relabeled_issuable_email', issuable: @issue %>
= render 'relabeled_issuable_email', issuable: @merge_request
<%= render 'relabeled_issuable_email', issuable: @merge_request %>
- page_title "Applications"
- header_title page_title, applications_profile_path
.alert.alert-help.prepend-top-default
- if user_oauth_applications?
Manage applications that can use GitLab as an OAuth provider,
and applications that you've authorized to use your account.
- else
Manage applications that you've authorized to use your account.
- if user_oauth_applications?
.oauth-applications
%h3
Your applications
.pull-right
= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success'
- if @applications.any?
.table-holder
%table.table.table-striped
%thead
%tr
%th Name
%th Callback URL
%th Clients
%th
%th
%tbody
- @applications.each do |application|
%tr{:id => "application_#{application.id}"}
%td= link_to application.name, oauth_application_path(application)
%td
- application.redirect_uri.split.each do |uri|
%div= uri
%td= application.access_tokens.count
%td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link btn-sm'
%td= render 'doorkeeper/applications/delete_form', application: application
.oauth-authorized-applications.prepend-top-20
- if user_oauth_applications?
%h3
Authorized applications
- if @authorized_tokens.any?
.table-holder
%table.table.table-striped
%thead
%tr
%th Name
%th Authorized At
%th Scope
%th
%tbody
- @authorized_apps.each do |app|
- token = app.authorized_tokens.order('created_at desc').first
%tr{:id => "application_#{app.id}"}
%td= app.name
%td= token.created_at
%td= token.scopes
%td= render 'doorkeeper/authorized_applications/delete_form', application: app
- @authorized_anonymous_tokens.each do |token|
%tr
%td
Anonymous
%div.help-block
%em Authorization was granted by entering your username and password in the application.
%td= token.created_at
%td= token.scopes
%td= render 'doorkeeper/authorized_applications/delete_form', token: token
- else
%p.light You don't have any authorized applications
= form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f|
= f.hidden_field :avatar_crop_x
= f.hidden_field :avatar_crop_y
= f.hidden_field :avatar_crop_size
-if @user.errors.any?
%div.alert.alert-danger
%ul
......@@ -97,19 +94,3 @@
.prepend-top-default.append-bottom-default
= f.submit 'Update profile settings', class: "btn btn-success"
= link_to "Cancel", user_path(current_user), class: "btn btn-cancel"
.modal.modal-profile-crop
.modal-dialog
.modal-content
.modal-header
%button.close{type: 'button', data: {dismiss: 'modal'}}
%span
&times;
%h4.modal-title
Crop your new profile picture
.modal-body
%p
%img.modal-profile-crop-image
.modal-footer
%button.btn.btn-primary.js-upload-user-avatar{:type => "button"}
Set new profile picture
......@@ -55,7 +55,6 @@
%th Coverage
%th
- @builds.each do |build|
= render 'projects/commit_statuses/commit_status', commit_status: build, commit_sha: true, stage: true, coverage: @project.build_coverage_enabled?, allow_retry: true
= render @builds, commit_sha: true, stage: true, allow_retry: true, coverage: @project.build_coverage_enabled?
= paginate @builds, theme: 'gitlab'
......@@ -13,9 +13,10 @@
= link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request)
#up-build-trace
- if @commit.matrix_for_ref?(@build.ref)
- builds = @build.commit.matrix_builds(@build)
- if builds.size > 1
%ul.nav-links.no-top.no-bottom
- @commit.latest_builds_for_ref(@build.ref).each do |build|
- builds.each do |build|
%li{class: ('active' if build == @build) }
= link_to namespace_project_build_path(@project.namespace, @project, build) do
= ci_icon_for_status(build.status)
......@@ -44,7 +45,7 @@
.pull-right
#{time_ago_with_tooltip(@build.finished_at) if @build.finished_at}
- if @build.show_warning?
- if @build.stuck?
- unless @build.any_runners_online?
.bs-callout.bs-callout-warning
%p
......@@ -100,12 +101,12 @@
%h4.title Build artifacts
.center
.btn-group{ role: :group }
= link_to @build.artifacts_download_url, class: 'btn btn-sm btn-primary' do
= link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do
= icon('download')
Download
- if @build.artifacts_metadata?
= link_to @build.artifacts_browse_url, class: 'btn btn-sm btn-primary' do
= link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do
= icon('folder-open')
Browse
......@@ -115,10 +116,10 @@
- if can?(current_user, :update_build, @project)
.center
.btn-group{ role: :group }
- if @build.cancel_url
= link_to "Cancel", @build.cancel_url, class: 'btn btn-sm btn-danger', method: :post
- elsif @build.retry_url
= link_to "Retry", @build.retry_url, class: 'btn btn-sm btn-primary', method: :post
- if @build.active?
= link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-danger', method: :post
- elsif @build.retryable?
= link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary', method: :post
- if @build.erasable?
= link_to erase_namespace_project_build_path(@project.namespace, @project, @build),
......
%tr.build
%td.status
- if can?(current_user, :read_build, build)
= ci_status_with_icon(build.status, namespace_project_build_url(build.project.namespace, build.project, build))
- else
= ci_status_with_icon(build.status)
%td.build-link
- if can?(current_user, :read_build, build)
= link_to namespace_project_build_url(build.project.namespace, build.project, build) do
%strong ##{build.id}
- else
%strong ##{build.id}
- if build.stuck?
%i.fa.fa-warning.text-warning
- if defined?(commit_sha) && commit_sha
%td
= link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace"
%td
- if build.ref
= link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref)
- else
.light none
- if defined?(runner) && runner
%td
- if build.try(:runner)
= runner_link(build.runner)
- else
.light none
- if defined?(stage) && stage
%td
= build.stage
%td
= build.name
.pull-right
- if build.tags.any?
- build.tags.each do |tag|
%span.label.label-primary
= tag
- if build.try(:trigger_request)
%span.label.label-info triggered
- if build.try(:allow_failure)
%span.label.label-danger allowed to fail
%td.duration
- if build.duration
#{duration_in_words(build.finished_at, build.started_at)}
%td.timestamp
- if build.finished_at
%span #{time_ago_with_tooltip(build.finished_at)}
- if defined?(coverage) && coverage
%td.coverage
- if build.try(:coverage)
#{build.coverage}%
%td
.pull-right
- if can?(current_user, :read_build, build) && build.artifacts?
= link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do
%i.fa.fa-download
- if can?(current_user, :update_build, build)
- if build.active?
= link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do
%i.fa.fa-remove.cred
- elsif defined?(allow_retry) && allow_retry && build.retryable?
= link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do
%i.fa.fa-repeat
......@@ -43,8 +43,8 @@
%th Coverage
%th
- @ci_commit.refs.each do |ref|
= render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.statuses.for_ref(ref).latest.ordered,
locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true }
- builds = @ci_commit.statuses.for_ref(ref).latest.ordered
= render builds, coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true
- if @ci_commit.retried.any?
.gray-content-block.second-block
......@@ -64,5 +64,4 @@
- if @ci_commit.project.build_coverage_enabled?
%th Coverage
%th
= render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.retried,
locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true }
= render @ci_commit.retried, coverage: @ci_commit.project.build_coverage_enabled?, stage: true
%tr.commit_status
%td.status
- if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url
= link_to commit_status.target_url, class: "ci-status ci-#{commit_status.status}" do
= ci_icon_for_status(commit_status.status)
= commit_status.status
- else
= ci_status_with_icon(commit_status.status)
%td.commit_status-link
- if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url
= link_to commit_status.target_url do
%strong ##{commit_status.id}
- else
%strong ##{commit_status.id}
- if commit_status.show_warning?
%i.fa.fa-warning.text-warning{data: { toggle: "tooltip" }, title: "This build is stuck, open it to know more"}
- if defined?(commit_sha) && commit_sha
%td
= link_to commit_status.short_sha, namespace_project_commit_path(commit_status.project.namespace, commit_status.project, commit_status.sha), class: "monospace"
%td
- if commit_status.ref
= link_to commit_status.ref, namespace_project_commits_path(commit_status.project.namespace, commit_status.project, commit_status.ref)
- else
.light none
- if defined?(runner) && runner
%td
- if commit_status.try(:runner)
= runner_link(commit_status.runner)
- else
.light none
- if defined?(stage) && stage
%td
= commit_status.stage
%td
= commit_status.name
.pull-right
- if commit_status.tags.any?
- commit_status.tags.each do |tag|
%span.label.label-primary
= tag
- if commit_status.try(:trigger_request)
%span.label.label-info triggered
- if commit_status.try(:allow_failure)
%span.label.label-danger allowed to fail
%td.duration
- if commit_status.duration
#{duration_in_words(commit_status.finished_at, commit_status.started_at)}
%td.timestamp
- if commit_status.finished_at
%span #{time_ago_with_tooltip(commit_status.finished_at)}
- if defined?(coverage) && coverage
%td.coverage
- if commit_status.try(:coverage)
#{commit_status.coverage}%
%td
.pull-right
- if can?(current_user, :read_commit_status, commit_status) && commit_status.artifacts_download_url
= link_to commit_status.artifacts_download_url, title: 'Download artifacts' do
%i.fa.fa-download
- if can?(current_user, :update_commit_status, commit_status)
- if commit_status.active?
- if commit_status.cancel_url
= link_to commit_status.cancel_url, method: :post, title: 'Cancel' do
%i.fa.fa-remove.cred
- elsif defined?(allow_retry) && allow_retry && commit_status.retry_url
= link_to commit_status.retry_url, method: :post, title: 'Retry' do
%i.fa.fa-repeat
%tr.generic_commit_status
%td.status
- if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url
= ci_status_with_icon(generic_commit_status.status, generic_commit_status.target_url)
- else
= ci_status_with_icon(generic_commit_status.status)
%td.generic_commit_status-link
- if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url
= link_to generic_commit_status.target_url do
%strong ##{generic_commit_status.id}
- else
%strong ##{generic_commit_status.id}
- if defined?(commit_sha) && commit_sha
%td
= link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "monospace"
%td
- if generic_commit_status.ref
= link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref)
- else
.light none
- if defined?(runner) && runner
%td
- if generic_commit_status.try(:runner)
= runner_link(generic_commit_status.runner)
- else
.light none
- if defined?(stage) && stage
%td
= generic_commit_status.stage
%td
= generic_commit_status.name
.pull-right
- if generic_commit_status.tags.any?
- generic_commit_status.tags.each do |tag|
%span.label.label-primary
= tag
%td.duration
- if generic_commit_status.duration
#{duration_in_words(generic_commit_status.finished_at, generic_commit_status.started_at)}
%td.timestamp
- if generic_commit_status.finished_at
%span #{time_ago_with_tooltip(generic_commit_status.finished_at)}
- if defined?(coverage) && coverage
%td.coverage
- if generic_commit_status.try(:coverage)
#{generic_commit_status.coverage}%
%td
......@@ -10,6 +10,16 @@
= link_to_label(label) do
= pluralize label.open_issues_count, 'open issue'
- if current_user
.label-subscription{data: {url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}}
.subscription-status{data: {status: label_subscription_status(label)}}
%button.btn.btn-sm.btn-info.subscribe-button
%span= label_subscription_toggle_button_text(label)
- if can? current_user, :admin_label, @project
= link_to 'Edit', edit_namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm'
= link_to 'Delete', namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Remove this label? Are you sure?"}
- if current_user
:javascript
new Subscription('##{dom_id(label)} .label-subscription');
......@@ -94,7 +94,7 @@
%a{href: "#", data: {id: "close"}} Closed
.filter-item.inline
= dropdown_tag("Assignee", options: { toggle_class: "js-user-search", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } })
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",
placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :js), use_id: true } })
......
......@@ -98,7 +98,7 @@
%hr
- if current_user
- subscribed = issuable.subscribed?(current_user)
.block.light
.block.light.subscription{data: {url: toggle_subscription_path(issuable)}}
.sidebar-collapsed-icon
= icon('rss')
.title.hide-collapsed
......@@ -124,5 +124,5 @@
= clipboard_button(clipboard_text: project_ref)
:javascript
new Subscription("#{toggle_subscription_path(issuable)}");
new Subscription('.subscription');
new IssuableContext();
......@@ -295,7 +295,7 @@ Rails.application.routes.draw do
resource :profile, only: [:show, :update] do
member do
get :audit_log
get :applications
get :applications, to: 'oauth/applications#index'
put :reset_private_token
put :update_username
......@@ -675,6 +675,10 @@ Rails.application.routes.draw do
collection do
post :generate
end
member do
post :toggle_subscription
end
end
resources :issues, constraints: { id: /\d+/ }, except: [:destroy] do
......
class ProjectsAddPushesSinceGc < ActiveRecord::Migration
def change
add_column :projects, :pushes_since_gc, :integer, default: 0
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20160310185910) do
ActiveRecord::Schema.define(version: 20160314143402) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -720,6 +720,7 @@ ActiveRecord::Schema.define(version: 20160310185910) do
t.boolean "pending_delete", default: false
t.boolean "public_builds", default: true, null: false
t.string "main_language"
t.integer "pushes_since_gc", default: 0
end
add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree
......
......@@ -33,7 +33,6 @@ Example of response
},
"coverage": null,
"created_at": "2015-12-24T15:51:21.802Z",
"download_url": null,
"artifacts_file": {
"filename": "artifacts.zip",
"size": 1000
......@@ -75,7 +74,6 @@ Example of response
},
"coverage": null,
"created_at": "2015-12-24T15:51:21.727Z",
"download_url": null,
"artifacts_file": null,
"finished_at": "2015-12-24T17:54:24.921Z",
"id": 6,
......@@ -139,7 +137,6 @@ Example of response
},
"coverage": null,
"created_at": "2016-01-11T10:13:33.506Z",
"download_url": null,
"artifacts_file": null,
"finished_at": "2016-01-11T10:14:09.526Z",
"id": 69,
......@@ -164,7 +161,6 @@ Example of response
},
"coverage": null,
"created_at": "2015-12-24T15:51:21.957Z",
"download_url": null,
"artifacts_file": null,
"finished_at": "2015-12-24T17:54:33.913Z",
"id": 9,
......@@ -226,7 +222,6 @@ Example of response
},
"coverage": null,
"created_at": "2015-12-24T15:51:21.880Z",
"download_url": null,
"artifacts_file": null,
"finished_at": "2015-12-24T17:54:31.198Z",
"id": 8,
......@@ -315,7 +310,6 @@ Example of response
},
"coverage": null,
"created_at": "2016-01-11T10:13:33.506Z",
"download_url": null,
"artifacts_file": null,
"finished_at": "2016-01-11T10:14:09.526Z",
"id": 69,
......@@ -362,7 +356,6 @@ Example of response
},
"coverage": null,
"created_at": "2016-01-11T10:13:33.506Z",
"download_url": null,
"artifacts_file": null,
"finished_at": null,
"id": 69,
......
## Test and Deploy a ruby application
This example will guide you how to run tests in your Ruby application and deploy it automatiacally as Heroku application.
This example will guide you how to run tests in your Ruby application and deploy it automatically as Heroku application.
You can checkout the example [source](https://gitlab.com/ayufan/ruby-getting-started) and check [CI status](https://gitlab.com/ayufan/ruby-getting-started/builds?scope=all).
......
......@@ -116,7 +116,8 @@ Alias for [stages](#stages).
### variables
_**Note:** Introduced in GitLab Runner v0.5.0._
>**Note:**
Introduced in GitLab Runner v0.5.0.
GitLab CI allows you to add to `.gitlab-ci.yml` variables that are set in build
environment. The variables are stored in the git repository and are meant to
......@@ -153,7 +154,8 @@ cache:
#### cache:key
_**Note:** Introduced in GitLab Runner v1.0.0._
>**Note:**
Introduced in GitLab Runner v1.0.0.
The `key` directive allows you to define the affinity of caching
between jobs, allowing to have a single cache for all jobs,
......@@ -234,13 +236,14 @@ job_name:
| Keyword | Required | Description |
|---------------|----------|-------------|
| script | yes | Defines a shell script which is executed by runner |
| stage | no (default: `test`) | Defines a build stage |
| stage | no | Defines a build stage (default: `test`) |
| type | no | Alias for `stage` |
| only | no | Defines a list of git refs for which build is created |
| except | no | Defines a list of git refs for which build is not created |
| tags | no | Defines a list of tags which are used to select runner |
| allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status |
| when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` |
| dependencies | no | Define other builds that a build depends on so that you can pass artifacts between them|
| artifacts | no | Define list build artifacts |
| cache | no | Define list of files that should be cached between subsequent runs |
......@@ -393,15 +396,18 @@ The above script will:
### artifacts
_**Note:** Introduced in GitLab Runner v0.7.0 for non-Windows platforms._
_**Note:** Limited Windows support was added in GitLab Runner v.1.0.0.
Currently not all executors are supported._
_**Note:** Build artifacts are only collected for successful builds._
>**Notes:**
>
> - Introduced in GitLab Runner v0.7.0 for non-Windows platforms.
> - Windows support was added in GitLab Runner v.1.0.0.
> - Currently not all executors are supported.
> - Build artifacts are only collected for successful builds.
`artifacts` is used to specify list of files and directories which should be
attached to build after success. Below are some examples.
attached to build after success. To pass artifacts between different builds,
see [dependencies](#dependencies).
Below are some examples.
Send all files in `binaries` and `.config`:
......@@ -453,9 +459,130 @@ release-job:
The artifacts will be sent to GitLab after a successful build and will
be available for download in the GitLab UI.
#### artifacts:name
>**Note:**
Introduced in GitLab 8.6 and GitLab Runner v1.1.0.
The `name` directive allows you to define the name of the created artifacts
archive. That way, you can have a unique name of every archive which could be
useful when you'd like to download the archive from GitLab. The `artifacts:name`
variable can make use of any of the [predefined variables](../variables/README.md).
---
**Example configurations**
To create an archive with a name of the current build:
```yaml
job:
artifacts:
name: "$CI_BUILD_NAME"
```
To create an archive with a name of the current branch or tag including only
the files that are untracked by Git:
```yaml
job:
artifacts:
name: "$CI_BUILD_REF_NAME"
untracked: true
```
To create an archive with a name of the current build and the current branch or
tag including only the files that are untracked by Git:
```yaml
job:
artifacts:
name: "${CI_BUILD_NAME}_${CI_BUILD_REF_NAME}"
untracked: true
```
To create an archive with a name of the current [stage](#stages) and branch name:
```yaml
job:
artifacts:
name: "${CI_BUILD_STAGE}_${CI_BUILD_REF_NAME}"
untracked: true
```
---
If you use **Windows Batch** to run your shell scripts you need to replace
`$` with `%`:
```yaml
job:
artifacts:
name: "%CI_BUILD_STAGE%_%CI_BUILD_REF_NAME%"
untracked: true
```
### dependencies
>**Note:**
Introduced in GitLab 8.6 and GitLab Runner v1.1.1.
This feature should be used in conjunction with [`artifacts`](#artifacts) and
allows you to define the artifacts to pass between different builds.
Note that `artifacts` from previous [stages](#stages) are passed by default.
To use this feature, define `dependencies` in context of the job and pass
a list of all previous builds from which the artifacts should be downloaded.
You can only define builds from stages that are executed before the current one.
An error will be shown if you define builds from the current stage or next ones.
---
In the following example, we define two jobs with artifacts, `build:osx` and
`build:linux`. When the `test:osx` is executed, the artifacts from `build:osx`
will be downloaded and extracted in the context of the build. The same happens
for `test:linux` and artifacts from `build:linux`.
The job `deploy` will download artifacts from all previous builds because of
the [stage](#stages) precedence:
```yaml
build:osx:
stage: build
script: make build:osx
artifacts:
paths:
- binaries/
build:linux:
stage: build
script: make build:linux
artifacts:
paths:
- binaries/
test:osx:
stage: test
script: make test:osx
dependencies:
- build:osx
test:linux:
stage: test
script: make test:linux
dependencies:
- build:linux
deploy:
stage: deploy
script: make deploy
```
### cache
_**Note:** Introduced in GitLab Runner v0.7.0._
>**Note:**
Introduced in GitLab Runner v0.7.0.
`cache` is used to specify list of files and directories which should be cached
between builds. Below are some examples:
......@@ -509,6 +636,155 @@ rspec:
The cache is provided on best effort basis, so don't expect that cache will be
always present. For implementation details please check GitLab Runner.
## Hidden jobs
>**Note:**
Introduced in GitLab 8.6 and GitLab Runner v1.1.1.
Jobs that start with a dot (`.`) will be not processed by GitLab CI. You can
use this feature to ignore jobs, or use the
[special YAML features](#special-yaml-features) and transform the hidden jobs
into templates.
In the following example, `.job_name` will be ignored:
```yaml
.job_name:
script:
- rake spec
```
## Special YAML features
It's possible to use special YAML features like anchors (`&`), aliases (`*`)
and map merging (`<<`), which will allow you to greatly reduce the complexity
of `.gitlab-ci.yml`.
Read more about the various [YAML features](https://learnxinyminutes.com/docs/yaml/).
### Anchors
>**Note:**
Introduced in GitLab 8.6 and GitLab Runner v1.1.1.
YAML also has a handy feature called 'anchors', which let you easily duplicate
content across your document. Anchors can be used to duplicate/inherit
properties, and is a perfect example to be used with [hidden jobs](#hidden-jobs)
to provide templates for your jobs.
The following example uses anchors and map merging. It will create two jobs,
`test1` and `test2`, that will inherit the parameters of `.job_template`, each
having their own custom `script` defined:
```yaml
.job_template: &job_definition # Hidden job that defines an anchor named 'job_definition'
image: ruby:2.1
services:
- postgres
- redis
test1:
<<: *job_definition # Merge the contents of the 'job_definition' alias
script:
- test1 project
test2:
<<: *job_definition # Merge the contents of the 'job_definition' alias
script:
- test2 project
```
`&` sets up the name of the anchor (`job_definition`), `<<` means "merge the
given hash into the current one", and `*` includes the named anchor
(`job_definition` again). The expanded version looks like this:
```yaml
.job_template:
image: ruby:2.1
services:
- postgres
- redis
test1:
image: ruby:2.1
services:
- postgres
- redis
script:
- test1 project
test2:
image: ruby:2.1
services:
- postgres
- redis
script:
- test2 project
```
Let's see another one example. This time we will use anchors to define two sets
of services. This will create two jobs, `test:postgres` and `test:mysql`, that
will share the `script` directive defined in `.job_template`, and the `services`
directive defined in `.postgres_services` and `.mysql_services` respectively:
```yaml
.job_template: &job_definition
script:
- test project
.postgres_services:
services: &postgres_definition
- postgres
- ruby
.mysql_services:
services: &mysql_definition
- mysql
- ruby
test:postgres:
<< *job_definition
services: *postgres_definition
test:mysql:
<< *job_definition
services: *mysql_definition
```
The expanded version looks like this:
```yaml
.job_template:
script:
- test project
.postgres_services:
services:
- postgres
- ruby
.mysql_services:
services:
- mysql
- ruby
test:postgres:
script:
- test project
services:
- postgres
- ruby
test:mysql:
script:
- test project
services:
- mysql
- ruby
```
You can see that the hidden jobs are conveniently used as templates.
## Validate the .gitlab-ci.yml
Each instance of GitLab CI has an embedded debug tool called Lint.
......
# SCSS styleguide
This style guide recommends best practices for SCSS to make styles easy to read,
easy to maintain, and performant for the end-user.
## Rules
### Naming
CSS classes should use the `lowercase-hyphenated` format rather than
`snake_case` or `camelCase`.
```scss
// Bad
.class_name {
color: #fff;
}
// Bad
.className {
color: #fff;
}
// Good
.class-name {
color: #fff;
}
```
### Formatting
You should always use a space before a brace, braces should be on the same
line, each property should each get its own line, and there should be a space
between the property and its value.
```scss
// Bad
.container-item {
width: 100px; height: 100px;
margin-top: 0;
}
// Bad
.container-item
{
width: 100px;
height: 100px;
margin-top: 0;
}
// Bad
.container-item{
width:100px;
height:100px;
margin-top:0;
}
// Good
.container-item {
width: 100px;
height: 100px;
margin-top: 0;
}
```
Note that there is an exception for single-line rulesets, although these are
not typically recommended.
```scss
p { margin: 0; padding: 0; }
```
### Colors
HEX (hexadecimal) colors short-form should use shortform where possible, and
should use lower case letters to differenciate between letters and numbers, e.
g. `#E3E3E3` vs. `#e3e3e3`.
```scss
// Bad
p {
color: #ffffff;
}
// Bad
p {
color: #FFFFFF;
}
// Good
p {
color: #fff;
}
```
### Indentation
Indentation should always use two spaces for each indentation level.
```scss
// Bad, four spaces
p {
color: #f00;
}
// Good
p {
color: #f00;
}
```
### Semicolons
Always include semicolons after every property. When the stylesheets are
minified, the semicolons will be removed automatically.
```scss
// Bad
.container-item {
width: 100px;
height: 100px
}
// Good
.container-item {
width: 100px;
height: 100px;
}
```
### Shorthand
The shorthand form should be used for properties that support it.
```scss
// Bad
margin: 10px 15px 10px 15px;
padding: 10px 10px 10px 10px;
// Good
margin: 10px 15px;
padding: 10px;
```
### Zero Units
Omit length units on zero values, they're unnecessary and not including them
is slightly more performant.
```scss
// Bad
.item-with-padding {
padding: 0px;
}
// Good
.item-with-padding {
padding: 0;
}
```
### Selectors with a `js-` Prefix
Do not use any selector prefixed with `js-` for styling purposes. These
selectors are intended for use only with JavaScript to allow for removal or
renaming without breaking styling.
## Linting
We use [SCSS Lint][scss-lint] to check for style guide conformity. It uses the
ruleset in `.scss-lint.yml`, which is located in the home directory of the
project.
To check if any warnings will be produced by your changes, you can run `rake
scss_lint` in the GitLab directory. SCSS Lint will also run in GitLab CI to
catch any warnings.
If the Rake task is throwing warnings you don't understand, SCSS Lint's
documentation includes [a full list of their linters][scss-lint-documentation].
### Fixing issues
If you want to automate changing a large portion of the codebase to conform to
the SCSS style guide, you can use [CSSComb][csscomb]. First install
[Node][node] and [NPM][npm], then run `npm install csscomb -g` to install
CSSComb globally (system-wide). Run it in the GitLab directory with
`csscomb app/assets/stylesheets` to automatically fix issues with CSS/SCSS.
Note that this won't fix every problem, but it should fix a majority.
[csscomb]: https://github.com/csscomb/csscomb.js
[node]: https://github.com/nodejs/node
[npm]: https://www.npmjs.com/
[scss-lint]: https://github.com/brigade/scss-lint
[scss-lint-documentation]: https://github.com/brigade/scss-lint/blob/master/lib/scss_lint/linter/README.md
......@@ -76,8 +76,7 @@ Feature: Profile
Scenario: I can manage application
Given I visit profile applications page
Then I click on new application button
And I should see application form
Then I should see application form
Then I fill application form out and submit
And I see application
Then I click edit
......
@labels
Feature: Labels
Background:
Given I sign in as a user
And I own project "Shop"
And project "Shop" has labels: "bug", "feature", "enhancement"
When I visit project "Shop" labels page
@javascript
Scenario: I can subscribe to a label
Then I should see that I am not subscribed to the "bug" label
When I click button "Subscribe" for the "bug" label
Then I should see that I am subscribed to the "bug" label
When I click button "Unsubscribe" for the "bug" label
Then I should see that I am not subscribed to the "bug" label
......@@ -46,11 +46,18 @@ Feature: Project Merge Requests
Then I should see "Feature NS-03" in merge requests
And I should see "Bug NS-04" in merge requests
Scenario: I visit merge request page
Scenario: I visit an open merge request page
Given I click link "Bug NS-04"
Then I should see merge request "Bug NS-04"
And I should see "1 of 1" in the sidebar
Scenario: I visit a merged merge request page
Given project "Shop" have "Feature NS-05" merged merge request
And I click link "Merged"
And I click link "Feature NS-05"
Then I should see merge request "Feature NS-05"
And I should see "3 of 3" in the sidebar
Scenario: I close merge request page
Given I click link "Bug NS-04"
And I click link "Close"
......
......@@ -27,7 +27,9 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
end
step 'I change my avatar' do
attach_avatar
attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif'))
click_button "Update profile settings"
@user.reload
end
step 'I should see new avatar' do
......@@ -40,7 +42,9 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
end
step 'I have an avatar' do
attach_avatar
attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif'))
click_button "Update profile settings"
@user.reload
end
step 'I remove my avatar' do
......@@ -180,18 +184,14 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
end
end
step 'I click on new application button' do
click_on 'New Application'
end
step 'I should see application form' do
expect(page).to have_content "New Application"
expect(page).to have_content "Add new application"
end
step 'I fill application form out and submit' do
fill_in :doorkeeper_application_name, with: 'test'
fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com'
click_on "Submit"
click_on "Save application"
end
step 'I see application' do
......@@ -211,7 +211,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step 'I change name of application and submit' do
expect(page).to have_content "Edit application"
fill_in :doorkeeper_application_name, with: 'test_changed'
click_on "Submit"
click_on "Save application"
end
step 'I see that application was changed' do
......@@ -229,16 +229,4 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step "I see that application is removed" do
expect(page.find(".oauth-applications")).not_to have_content "test_changed"
end
def attach_avatar
attach_file :user_avatar, Rails.root.join(*%w(spec fixtures banana_sample.gif))
page.find('#user_avatar_crop_x', visible: false).set('0')
page.find('#user_avatar_crop_y', visible: false).set('0')
page.find('#user_avatar_crop_size', visible: false).set('256')
click_button "Update profile settings"
@user.reload
end
end
......@@ -13,7 +13,6 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps
thumbsup = page.first('.award-control')
thumbsup.click
thumbsup.hover
sleep 0.3
end
end
......@@ -46,12 +45,10 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps
end
step 'I have award added' do
sleep 0.2
page.within '.awards' do
expect(page).to have_selector '.js-emoji-btn'
expect(page.find('.js-emoji-btn.active .js-counter')).to have_content '1'
expect(page.find('.js-emoji-btn.active')['data-original-title']).to eq('me')
expect(page).to have_css(".js-emoji-btn.active[data-original-title='me']")
end
end
......
class Spinach::Features::Labels < Spinach::FeatureSteps
include SharedAuthentication
include SharedIssuable
include SharedProject
include SharedNote
include SharedPaths
include SharedMarkdown
step 'And I visit project "Shop" labels page' do
visit namespace_project_labels_path(project.namespace, project)
end
step 'I should see that I am subscribed to the "bug" label' do
expect(subscribe_button).to have_content 'Unsubscribe'
end
step 'I should see that I am not subscribed to the "bug" label' do
expect(subscribe_button).to have_content 'Subscribe'
end
step 'I click button "Unsubscribe" for the "bug" label' do
subscribe_button.click
end
step 'I click button "Subscribe" for the "bug" label' do
subscribe_button.click
end
private
def subscribe_button
first('.subscribe-button span')
end
end
......@@ -16,10 +16,18 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
click_link "Bug NS-04"
end
step 'I click link "Feature NS-05"' do
click_link "Feature NS-05"
end
step 'I click link "All"' do
click_link "All"
end
step 'I click link "Merged"' do
click_link "Merged"
end
step 'I click link "Closed"' do
click_link "Closed"
end
......@@ -40,6 +48,10 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
expect(page).to have_content "Bug NS-04"
end
step 'I should see merge request "Feature NS-05"' do
expect(page).to have_content "Feature NS-05"
end
step 'I should not see "master" branch' do
expect(find('.merge-request-info')).not_to have_content "master"
end
......@@ -120,6 +132,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
author: project.users.first)
end
step 'project "Shop" have "Feature NS-05" merged merge request' do
create(:merged_merge_request,
title: "Feature NS-05",
source_project: project,
target_project: project,
author: project.users.first)
end
step 'project "Shop" have "Bug NS-07" open merge request with rebased branch' do
create(:merge_request, :rebased,
title: "Bug NS-07",
......
......@@ -68,7 +68,7 @@ module SharedBuilds
end
step 'I see the build' do
page.within('.commit_status') do
page.within('.build') do
expect(page).to have_content "##{@build.id}"
expect(page).to have_content @build.sha[0..7]
expect(page).to have_content @build.ref
......
......@@ -147,6 +147,10 @@ module SharedIssuable
expect_sidebar_content('2 of 2')
end
step 'I should see "3 of 3" in the sidebar' do
expect_sidebar_content('3 of 3')
end
step 'I click link "Next" in the sidebar' do
page.within '.issuable-sidebar' do
click_link 'Next'
......
......@@ -409,13 +409,6 @@ module API
expose :id, :status, :stage, :name, :ref, :tag, :coverage
expose :created_at, :started_at, :finished_at
expose :user, with: User
# TODO: download_url in Ci:Build model is an GitLab Web Interface URL, not API URL. We should think on some API
# for downloading of artifacts (see: https://gitlab.com/gitlab-org/gitlab-ce/issues/4255)
expose :download_url do |repo_obj, options|
if options[:user_can_download_artifacts]
repo_obj.artifacts_download_url
end
end
expose :artifacts_file, using: BuildArtifactFile, if: -> (build, opts) { build.artifacts? }
expose :commit, with: RepoCommit do |repo_obj, _options|
if repo_obj.respond_to?(:commit)
......
......@@ -7,7 +7,7 @@ module Banzai
#
# Extends HTML::Pipeline::SanitizationFilter with a custom whitelist.
class SanitizationFilter < HTML::Pipeline::SanitizationFilter
UNSAFE_PROTOCOLS = %w(javascript :javascript data vbscript).freeze
UNSAFE_PROTOCOLS = %w(data javascript vbscript).freeze
def whitelist
whitelist = super
......@@ -64,7 +64,12 @@ module Banzai
return unless node.name == 'a'
return unless node.has_attribute?('href')
if node['href'].start_with?(*UNSAFE_PROTOCOLS)
begin
uri = Addressable::URI.parse(node['href'])
uri.scheme.strip! if uri.scheme
node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme)
rescue Addressable::URI::InvalidURIError
node.remove_attribute('href')
end
end
......
......@@ -5,7 +5,9 @@ module Ci
DEFAULT_STAGES = %w(build test deploy)
DEFAULT_STAGE = 'test'
ALLOWED_YAML_KEYS = [:before_script, :image, :services, :types, :stages, :variables, :cache]
ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache]
ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services,
:allow_failure, :type, :stage, :when, :artifacts, :cache,
:dependencies]
attr_reader :before_script, :image, :services, :variables, :path, :cache
......@@ -60,6 +62,7 @@ module Ci
@jobs = {}
@config.each do |key, job|
next if key.to_s.start_with?('.')
stage = job[:stage] || job[:type] || DEFAULT_STAGE
@jobs[key] = { stage: stage }.merge(job)
end
......@@ -81,6 +84,7 @@ module Ci
services: job[:services] || @services,
artifacts: job[:artifacts],
cache: job[:cache] || @cache,
dependencies: job[:dependencies],
}.compact
}
end
......@@ -143,6 +147,7 @@ module Ci
validate_job_stage!(name, job) if job[:stage]
validate_job_cache!(name, job) if job[:cache]
validate_job_artifacts!(name, job) if job[:artifacts]
validate_job_dependencies!(name, job) if job[:dependencies]
end
private
......@@ -216,6 +221,10 @@ module Ci
end
def validate_job_artifacts!(name, job)
if job[:artifacts][:name] && !validate_string(job[:artifacts][:name])
raise ValidationError, "#{name} job: artifacts:name parameter should be a string"
end
if job[:artifacts][:untracked] && !validate_boolean(job[:artifacts][:untracked])
raise ValidationError, "#{name} job: artifacts:untracked parameter should be an boolean"
end
......@@ -225,6 +234,22 @@ module Ci
end
end
def validate_job_dependencies!(name, job)
if !validate_array_of_strings(job[:dependencies])
raise ValidationError, "#{name} job: dependencies parameter should be an array of strings"
end
stage_index = stages.index(job[:stage])
job[:dependencies].each do |dependency|
raise ValidationError, "#{name} job: undefined dependency: #{dependency}" unless @jobs[dependency]
unless stages.index(@jobs[dependency][:stage]) < stage_index
raise ValidationError, "#{name} job: dependency #{dependency} is not defined in prior stages"
end
end
end
def validate_array_of_strings(values)
values.is_a?(Array) && values.all? { |value| validate_string(value) }
end
......
unless Rails.env.production?
require 'scss_lint/rake_task'
SCSSLint::RakeTask.new do |t|
t.config = '.scss-lint.yml'
# See https://github.com/brigade/scss-lint/issues/726
# Hack, otherwise linter won't respect scss_files option in config file.
t.files = []
end
end
require 'spec_helper'
describe NamespacesController do
let!(:user) { create(:user, :with_avatar) }
let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
describe "GET show" do
context "when the namespace belongs to a user" do
......
require 'spec_helper'
describe Profiles::AvatarsController do
let(:user) { create(:user, :with_avatar) }
let(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png")) }
before do
sign_in(user)
......
require 'spec_helper'
describe UploadsController do
let!(:user) { create(:user, :with_avatar) }
let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
describe "GET show" do
context "when viewing a user avatar" do
......
......@@ -13,7 +13,7 @@
FactoryGirl.define do
factory :label do
title "Bug"
sequence(:title) { |n| "label#{n}" }
color "#990000"
project
end
......
......@@ -56,6 +56,10 @@ FactoryGirl.define do
target_branch "feature"
end
trait :merged do
state :merged
end
trait :closed do
state :closed
end
......@@ -84,6 +88,7 @@ FactoryGirl.define do
merge_user author
end
factory :merged_merge_request, traits: [:merged]
factory :closed_merge_request, traits: [:closed]
factory :reopened_merge_request, traits: [:reopened]
factory :merge_request_with_diffs, traits: [:with_diffs]
......
......@@ -23,13 +23,6 @@ FactoryGirl.define do
end
end
trait :with_avatar do
avatar { fixture_file_upload(Rails.root.join(*%w(spec fixtures dk.png)), 'image/png') }
avatar_crop_x 0
avatar_crop_y 0
avatar_crop_size 256
end
factory :omniauth_user do
transient do
extern_uid '123456'
......
......@@ -77,7 +77,7 @@ describe ApplicationHelper do
let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') }
it 'should return an url for the avatar' do
user = create(:user, :with_avatar, avatar: File.open(avatar_file_path))
user = create(:user, avatar: File.open(avatar_file_path))
expect(helper.avatar_icon(user.email).to_s).
to match("/uploads/user/avatar/#{user.id}/banana_sample.gif")
......@@ -88,7 +88,7 @@ describe ApplicationHelper do
# Must be stubbed after the stub above, and separately
stub_config_setting(url: Settings.send(:build_gitlab_url))
user = create(:user, :with_avatar, avatar: File.open(avatar_file_path))
user = create(:user, avatar: File.open(avatar_file_path))
expect(helper.avatar_icon(user.email).to_s).
to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif")
......@@ -102,7 +102,7 @@ describe ApplicationHelper do
describe 'using a User' do
it 'should return an URL for the avatar' do
user = create(:user, :with_avatar, avatar: File.open(avatar_file_path))
user = create(:user, avatar: File.open(avatar_file_path))
expect(helper.avatar_icon(user).to_s).
to match("/uploads/user/avatar/#{user.id}/banana_sample.gif")
......
......@@ -149,10 +149,20 @@ describe Banzai::Filter::SanitizationFilter, lib: true do
output: '<a href="java"></a>'
},
'protocol-based JS injection: invalid URL char' => {
input: '<img src=java\script:alert("XSS")>',
output: '<img>'
},
'protocol-based JS injection: spaces and entities' => {
input: '<a href=" &#14; javascript:alert(\'XSS\');">foo</a>',
output: '<a href="">foo</a>'
},
'protocol whitespace' => {
input: '<a href=" http://example.com/"></a>',
output: '<a href="http://example.com/"></a>'
}
}
protocols.each do |name, data|
......@@ -177,6 +187,16 @@ describe Banzai::Filter::SanitizationFilter, lib: true do
expect(output.to_html).to eq '<a>XSS</a>'
end
it 'disallows invalid URIs' do
expect(Addressable::URI).to receive(:parse).with('foo://example.com').
and_raise(Addressable::URI::InvalidURIError)
input = '<a href="foo://example.com">Foo</a>'
output = filter(input)
expect(output.to_html).to eq '<a>Foo</a>'
end
it 'allows non-standard anchor schemes' do
exp = %q{<a href="irc://irc.freenode.net/git">IRC</a>}
act = filter(exp)
......
......@@ -397,7 +397,7 @@ module Ci
services: ["mysql"],
before_script: ["pwd"],
rspec: {
artifacts: { paths: ["logs/", "binaries/"], untracked: true },
artifacts: { paths: ["logs/", "binaries/"], untracked: true, name: "custom_name" },
script: "rspec"
}
})
......@@ -417,6 +417,7 @@ module Ci
image: "ruby:2.1",
services: ["mysql"],
artifacts: {
name: "custom_name",
paths: ["logs/", "binaries/"],
untracked: true
}
......@@ -427,6 +428,73 @@ module Ci
end
end
describe "Dependencies" do
let(:config) do
{
build1: { stage: 'build', script: 'test' },
build2: { stage: 'build', script: 'test' },
test1: { stage: 'test', script: 'test', dependencies: dependencies },
test2: { stage: 'test', script: 'test' },
deploy: { stage: 'test', script: 'test' }
}
end
subject { GitlabCiYamlProcessor.new(YAML.dump(config)) }
context 'no dependencies' do
let(:dependencies) { }
it { expect { subject }.to_not raise_error }
end
context 'dependencies to builds' do
let(:dependencies) { [:build1, :build2] }
it { expect { subject }.to_not raise_error }
end
context 'undefined dependency' do
let(:dependencies) { [:undefined] }
it { expect { subject }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'test1 job: undefined dependency: undefined') }
end
context 'dependencies to deploy' do
let(:dependencies) { [:deploy] }
it { expect { subject }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'test1 job: dependency deploy is not defined in prior stages') }
end
end
describe "Hidden jobs" do
let(:config) do
YAML.dump({
'.hidden_job' => { script: 'test' },
'normal_job' => { script: 'test' }
})
end
let(:config_processor) { GitlabCiYamlProcessor.new(config) }
subject { config_processor.builds_for_stage_and_ref("test", "master") }
it "doesn't create jobs that starts with dot" do
expect(subject.size).to eq(1)
expect(subject.first).to eq({
except: nil,
stage: "test",
stage_idx: 1,
name: :normal_job,
only: nil,
commands: "\ntest",
tag_list: [],
options: {},
when: "on_success",
allow_failure: false
})
end
end
describe "YAML Alias/Anchor" do
it "is correctly supported for jobs" do
config = <<EOT
......@@ -629,6 +697,13 @@ EOT
end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: when parameter should be on_success, on_failure or always")
end
it "returns errors if job artifacts:name is not an a string" do
config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { name: 1 } } })
expect do
GitlabCiYamlProcessor.new(config)
end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:name parameter should be a string")
end
it "returns errors if job artifacts:untracked is not an array of strings" do
config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } })
expect do
......@@ -684,6 +759,13 @@ EOT
GitlabCiYamlProcessor.new(config)
end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: cache:paths parameter should be an array of strings")
end
it "returns errors if job dependencies is not an array of strings" do
config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", dependencies: "string" } })
expect do
GitlabCiYamlProcessor.new(config)
end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: dependencies parameter should be an array of strings")
end
end
end
end
......@@ -11,7 +11,7 @@ describe Notify do
let(:example_site_path) { root_path }
let(:new_user) { create(:user, email: new_user_address, created_by_id: 1) }
let(:token) { 'kETLwRaayvigPq_x3SNM' }
subject { Notify.new_user_email(new_user.id, token) }
it_behaves_like 'an email sent from GitLab'
......@@ -77,6 +77,10 @@ describe Notify do
it 'includes a link to ssh keys page' do
is_expected.to have_body_text /#{profile_keys_path}/
end
context 'with SSH key that does not exist' do
it { expect { Notify.new_ssh_key_email('foo') }.not_to raise_error }
end
end
describe 'user added email' do
......
......@@ -100,6 +100,34 @@ describe Notify do
end
end
describe 'that have been relabeled' 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 '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'
it 'is sent as the author' do
sender = subject.header[:from].addrs[0]
expect(sender.display_name).to eq(current_user.name)
expect(sender.address).to eq(gitlab_sender)
end
it 'has the correct subject' do
is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/
end
it 'contains the names of the added labels' do
is_expected.to have_body_text /foo, bar, and baz/
end
it 'contains a link to the issue' do
is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/
end
end
describe 'status changed' do
let(:status) { 'closed' }
subject { Notify.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) }
......@@ -219,6 +247,34 @@ describe Notify do
end
end
describe 'that have been relabeled' 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 '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'
it 'is sent as the author' do
sender = subject.header[:from].addrs[0]
expect(sender.display_name).to eq(current_user.name)
expect(sender.address).to eq(gitlab_sender)
end
it 'has the correct subject' do
is_expected.to have_subject /#{merge_request.title} \(##{merge_request.iid}\)/
end
it 'contains the names of the added labels' do
is_expected.to have_body_text /foo, bar, and baz/
end
it 'contains a link to the merge request' do
is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/
end
end
describe 'status changed' do
let(:status) { 'reopened' }
subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) }
......
......@@ -112,6 +112,10 @@ shared_examples 'an unsubscribeable thread' do
it { is_expected.to have_body_text /unsubscribe/ }
end
shared_examples "a user cannot unsubscribe through footer link" do
shared_examples 'a user cannot unsubscribe through footer link' do
it { is_expected.not_to have_body_text /unsubscribe/ }
end
shared_examples 'an email with a labels subscriptions link in its footer' do
it { is_expected.to have_body_text /label subscriptions/ }
end
......@@ -9,7 +9,7 @@ describe Ci::Build, models: true do
it { is_expected.to respond_to :trace_html }
describe :first_pending do
describe '#first_pending' do
let(:first) { FactoryGirl.create :ci_build, commit: commit, status: 'pending', created_at: Date.yesterday }
let(:second) { FactoryGirl.create :ci_build, commit: commit, status: 'pending' }
before { first; second }
......@@ -19,7 +19,7 @@ describe Ci::Build, models: true do
it('returns with the first pending build') { is_expected.to eq(first) }
end
describe :create_from do
describe '#create_from' do
before do
build.status = 'success'
build.save
......@@ -33,7 +33,7 @@ describe Ci::Build, models: true do
end
end
describe :ignored? do
describe '#ignored?' do
subject { build.ignored? }
context 'if build is not allowed to fail' do
......@@ -69,7 +69,7 @@ describe Ci::Build, models: true do
end
end
describe :trace do
describe '#trace' do
subject { build.trace_html }
it { is_expected.to be_empty }
......@@ -101,7 +101,7 @@ describe Ci::Build, models: true do
# it { is_expected.to eq(commit.project.timeout) }
# end
describe :options do
describe '#options' do
let(:options) do
{
image: "ruby:2.1",
......@@ -122,25 +122,25 @@ describe Ci::Build, models: true do
# it { is_expected.to eq(project.allow_git_fetch) }
# end
describe :project do
describe '#project' do
subject { build.project }
it { is_expected.to eq(commit.project) }
end
describe :project_id do
describe '#project_id' do
subject { build.project_id }
it { is_expected.to eq(commit.project_id) }
end
describe :project_name do
describe '#project_name' do
subject { build.project_name }
it { is_expected.to eq(project.name) }
end
describe :extract_coverage do
describe '#extract_coverage' do
context 'valid content & regex' do
subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', '\(\d+.\d+\%\) covered') }
......@@ -172,7 +172,7 @@ describe Ci::Build, models: true do
end
end
describe :variables do
describe '#variables' do
context 'returns variables' do
subject { build.variables }
......@@ -242,7 +242,7 @@ describe Ci::Build, models: true do
end
end
describe :can_be_served? do
describe '#can_be_served?' do
let(:runner) { FactoryGirl.create :ci_runner }
before { build.project.runners << runner }
......@@ -277,7 +277,7 @@ describe Ci::Build, models: true do
end
end
describe :any_runners_online? do
describe '#any_runners_online?' do
subject { build.any_runners_online? }
context 'when no runners' do
......@@ -312,8 +312,8 @@ describe Ci::Build, models: true do
end
end
describe :show_warning? do
subject { build.show_warning? }
describe '#stuck?' do
subject { build.stuck? }
%w(pending).each do |state|
context "if commit_status.status is #{state}" do
......@@ -343,35 +343,7 @@ describe Ci::Build, models: true do
end
end
describe :artifacts_download_url do
subject { build.artifacts_download_url }
context 'artifacts file does not exist' do
before { build.update_attributes(artifacts_file: nil) }
it { is_expected.to be_nil }
end
context 'artifacts file exists' do
let(:build) { create(:ci_build, :artifacts) }
it { is_expected.to_not be_nil }
end
end
describe :artifacts_browse_url do
subject { build.artifacts_browse_url }
it "should be nil if artifacts browser is unsupported" do
allow(build).to receive(:artifacts_metadata?).and_return(false)
is_expected.to be_nil
end
it 'should not be nil if artifacts browser is supported' do
allow(build).to receive(:artifacts_metadata?).and_return(true)
is_expected.to_not be_nil
end
end
describe :artifacts? do
describe '#artifacts?' do
subject { build.artifacts? }
context 'artifacts archive does not exist' do
......@@ -386,7 +358,7 @@ describe Ci::Build, models: true do
end
describe :artifacts_metadata? do
describe '#artifacts_metadata?' do
subject { build.artifacts_metadata? }
context 'artifacts metadata does not exist' do
it { is_expected.to be_falsy }
......@@ -398,7 +370,7 @@ describe Ci::Build, models: true do
end
end
describe :repo_url do
describe '#repo_url' do
let(:build) { FactoryGirl.create :ci_build }
let(:project) { build.project }
......@@ -412,7 +384,7 @@ describe Ci::Build, models: true do
it { is_expected.to include(project.web_url[7..-1]) }
end
describe :depends_on_builds do
describe '#depends_on_builds' do
let!(:build) { FactoryGirl.create :ci_build, commit: commit, name: 'build', stage_idx: 0, stage: 'build' }
let!(:rspec_test) { FactoryGirl.create :ci_build, commit: commit, name: 'rspec', stage_idx: 1, stage: 'test' }
let!(:rubocop_test) { FactoryGirl.create :ci_build, commit: commit, name: 'rubocop', stage_idx: 1, stage: 'test' }
......@@ -444,7 +416,7 @@ describe Ci::Build, models: true do
created_at: created_at)
end
describe :merge_request do
describe '#merge_request' do
context 'when a MR has a reference to the commit' do
before do
@merge_request = create_mr(build, commit, factory: :merge_request)
......
......@@ -32,50 +32,6 @@ describe Ci::Commit, models: true do
it { is_expected.to respond_to :git_author_email }
it { is_expected.to respond_to :short_sha }
describe :ordered do
let(:project) { FactoryGirl.create :empty_project }
it 'returns ordered list of commits' do
commit1 = FactoryGirl.create :ci_commit, committed_at: 1.hour.ago, project: project
commit2 = FactoryGirl.create :ci_commit, committed_at: 2.hours.ago, project: project
expect(project.ci_commits.ordered).to eq([commit2, commit1])
end
it 'returns commits ordered by committed_at and id, with nulls last' do
commit1 = FactoryGirl.create :ci_commit, committed_at: 1.hour.ago, project: project
commit2 = FactoryGirl.create :ci_commit, committed_at: nil, project: project
commit3 = FactoryGirl.create :ci_commit, committed_at: 2.hours.ago, project: project
commit4 = FactoryGirl.create :ci_commit, committed_at: nil, project: project
expect(project.ci_commits.ordered).to eq([commit2, commit4, commit3, commit1])
end
end
describe :last_build do
subject { commit.last_build }
before do
@first = FactoryGirl.create :ci_build, commit: commit, created_at: Date.yesterday
@second = FactoryGirl.create :ci_build, commit: commit
end
it { is_expected.to be_a(Ci::Build) }
it('returns with the most recently created build') { is_expected.to eq(@second) }
end
describe :retry do
before do
@first = FactoryGirl.create :ci_build, commit: commit, created_at: Date.yesterday
@second = FactoryGirl.create :ci_build, commit: commit
end
it "creates only a new build" do
expect(commit.builds.count(:all)).to eq 2
expect(commit.statuses.count(:all)).to eq 2
commit.retry
expect(commit.builds.count(:all)).to eq 3
expect(commit.statuses.count(:all)).to eq 3
end
end
describe :valid_commit_sha do
context 'commit.sha can not start with 00000000' do
before do
......
......@@ -113,6 +113,48 @@ describe Issue, "Issuable" do
end
end
describe '#subscribed?' do
context 'user is not a participant in the issue' do
before { allow(issue).to receive(:participants).with(user).and_return([]) }
it 'returns false when no subcription exists' do
expect(issue.subscribed?(user)).to be_falsey
end
it 'returns true when a subcription exists and subscribed is true' do
issue.subscriptions.create(user: user, subscribed: true)
expect(issue.subscribed?(user)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
issue.subscriptions.create(user: user, subscribed: false)
expect(issue.subscribed?(user)).to be_falsey
end
end
context 'user is a participant in the issue' do
before { allow(issue).to receive(:participants).with(user).and_return([user]) }
it 'returns false when no subcription exists' do
expect(issue.subscribed?(user)).to be_truthy
end
it 'returns true when a subcription exists and subscribed is true' do
issue.subscriptions.create(user: user, subscribed: true)
expect(issue.subscribed?(user)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
issue.subscriptions.create(user: user, subscribed: false)
expect(issue.subscribed?(user)).to be_falsey
end
end
end
describe "#to_hook_data" do
let(:data) { issue.to_hook_data(user) }
let(:project) { issue.project }
......
require 'spec_helper'
describe Subscribable, 'Subscribable' do
let(:resource) { create(:issue) }
let(:user) { create(:user) }
describe '#subscribed?' do
it 'returns false when no subcription exists' do
expect(resource.subscribed?(user)).to be_falsey
end
it 'returns true when a subcription exists and subscribed is true' do
resource.subscriptions.create(user: user, subscribed: true)
expect(resource.subscribed?(user)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
resource.subscriptions.create(user: user, subscribed: false)
expect(resource.subscribed?(user)).to be_falsey
end
end
describe '#subscribers' do
it 'returns [] when no subcribers exists' do
expect(resource.subscribers).to be_empty
end
it 'returns the subscribed users' do
resource.subscriptions.create(user: user, subscribed: true)
resource.subscriptions.create(user: create(:user), subscribed: false)
expect(resource.subscribers).to eq [user]
end
end
describe '#toggle_subscription' do
it 'toggles the current subscription state for the given user' do
expect(resource.subscribed?(user)).to be_falsey
resource.toggle_subscription(user)
expect(resource.subscribed?(user)).to be_truthy
end
end
describe '#unsubscribe' do
it 'unsubscribes the given current user' do
resource.subscriptions.create(user: user, subscribed: true)
expect(resource.subscribed?(user)).to be_truthy
resource.unsubscribe(user)
expect(resource.subscribed?(user)).to be_falsey
end
end
end
......@@ -634,6 +634,12 @@ describe Project, models: true do
it 'returns projects with a matching namespace name regardless of the casing' do
expect(described_class.search(project.namespace.name.upcase)).to eq([project])
end
it 'returns projects when eager loading namespaces' do
relation = described_class.all.includes(:namespace)
expect(relation.search(project.namespace.name)).to eq([project])
end
end
describe '#rename_repo' do
......
......@@ -174,32 +174,6 @@ describe User, models: true do
end
end
end
describe 'avatar' do
it 'only validates when avatar is present and changed' do
user = build(:user, :with_avatar)
user.avatar_crop_x = nil
user.avatar_crop_y = nil
user.avatar_crop_size = nil
expect(user).not_to be_valid
expect(user.errors.keys).
to match_array %i(avatar_crop_x avatar_crop_y avatar_crop_size)
end
it 'does not validate when avatar has not changed' do
user = create(:user, :with_avatar)
expect { user.avatar_crop_x = nil }.not_to change(user, :valid?)
end
it 'does not validate when avatar is not present' do
user = create(:user)
expect { user.avatar_crop_y = nil }.not_to change(user, :valid?)
end
end
end
describe "Respond to" do
......
......@@ -401,6 +401,45 @@ describe GitPushService, services: true do
end
end
describe "housekeeping" do
let(:housekeeping) { Projects::HousekeepingService.new(project) }
before do
allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping)
end
it 'does not perform housekeeping when not needed' do
expect(housekeeping).not_to receive(:execute)
execute_service(project, user, @oldrev, @newrev, @ref)
end
context 'when housekeeping is needed' do
before do
allow(housekeeping).to receive(:needed?).and_return(true)
end
it 'performs housekeeping' do
expect(housekeeping).to receive(:execute)
execute_service(project, user, @oldrev, @newrev, @ref)
end
it 'does not raise an exception' do
allow(housekeeping).to receive(:try_obtain_lease).and_return(false)
execute_service(project, user, @oldrev, @newrev, @ref)
end
end
it 'increments the push counter' do
expect(housekeeping).to receive(:increment!)
execute_service(project, user, @oldrev, @newrev, @ref)
end
end
def execute_service(project, user, oldrev, newrev, ref)
service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref )
service.execute
......
......@@ -6,6 +6,7 @@ describe Issues::UpdateService, services: true do
let(:user3) { create(:user) }
let(:issue) { create(:issue, title: 'Old title', assignee_id: user3.id) }
let(:label) { create(:label) }
let(:label2) { create(:label) }
let(:project) { issue.project }
before do
......@@ -48,7 +49,7 @@ describe Issues::UpdateService, services: true do
it { expect(@issue.assignee).to eq(user2) }
it { expect(@issue).to be_closed }
it { expect(@issue.labels.count).to eq(1) }
it { expect(@issue.labels.first.title).to eq('Bug') }
it { expect(@issue.labels.first.title).to eq(label.name) }
it 'should send email to user2 about assign of new issue and email to user3 about issue unassignment' do
deliveries = ActionMailer::Base.deliveries
......@@ -148,6 +149,48 @@ describe Issues::UpdateService, services: true do
end
end
context 'when the issue is relabeled' do
let!(:non_subscriber) { create(:user) }
let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } }
it 'sends notifications for subscribers of newly added labels' do
opts = { label_ids: [label.id] }
perform_enqueued_jobs do
@issue = Issues::UpdateService.new(project, user, opts).execute(issue)
end
should_email(subscriber)
should_not_email(non_subscriber)
end
context 'when issue has the `label` label' do
before { issue.labels << label }
it 'does not send notifications for existing labels' do
opts = { label_ids: [label.id, label2.id] }
perform_enqueued_jobs do
@issue = Issues::UpdateService.new(project, user, opts).execute(issue)
end
should_not_email(subscriber)
should_not_email(non_subscriber)
end
it 'does not send notifications for removed labels' do
opts = { label_ids: [label2.id] }
perform_enqueued_jobs do
@issue = Issues::UpdateService.new(project, user, opts).execute(issue)
end
should_not_email(subscriber)
should_not_email(non_subscriber)
end
end
end
context 'when Issue has tasks' do
before { update_issue({ description: "- [ ] Task 1\n- [ ] Task 2" }) }
......
......@@ -7,6 +7,7 @@ describe MergeRequests::UpdateService, services: true do
let(:merge_request) { create(:merge_request, :simple, title: 'Old title', assignee_id: user3.id) }
let(:project) { merge_request.project }
let(:label) { create(:label) }
let(:label2) { create(:label) }
before do
project.team << [user, :master]
......@@ -53,7 +54,7 @@ describe MergeRequests::UpdateService, services: true do
it { expect(@merge_request.assignee).to eq(user2) }
it { expect(@merge_request).to be_closed }
it { expect(@merge_request.labels.count).to eq(1) }
it { expect(@merge_request.labels.first.title).to eq('Bug') }
it { expect(@merge_request.labels.first.title).to eq(label.name) }
it { expect(@merge_request.target_branch).to eq('target') }
it 'should execute hooks with update action' do
......@@ -176,6 +177,48 @@ describe MergeRequests::UpdateService, services: true do
end
end
context 'when the issue is relabeled' do
let!(:non_subscriber) { create(:user) }
let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } }
it 'sends notifications for subscribers of newly added labels' do
opts = { label_ids: [label.id] }
perform_enqueued_jobs do
@merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
should_email(subscriber)
should_not_email(non_subscriber)
end
context 'when issue has the `label` label' do
before { merge_request.labels << label }
it 'does not send notifications for existing labels' do
opts = { label_ids: [label.id, label2.id] }
perform_enqueued_jobs do
@merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
should_not_email(subscriber)
should_not_email(non_subscriber)
end
it 'does not send notifications for removed labels' do
opts = { label_ids: [label2.id] }
perform_enqueued_jobs do
@merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
should_not_email(subscriber)
should_not_email(non_subscriber)
end
end
end
context 'when MergeRequest has tasks' do
before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) }
......
......@@ -224,6 +224,15 @@ describe NotificationService, services: true do
should_not_email(issue.assignee)
end
it "emails subscribers of the issue's labels" do
subscriber = create(:user)
label = create(:label, issues: [issue])
label.toggle_subscription(subscriber)
notification.new_issue(issue, @u_disabled)
should_email(subscriber)
end
end
describe :reassigned_issue do
......@@ -296,6 +305,35 @@ describe NotificationService, services: true do
end
end
describe '#relabeled_issue' do
let(:label) { create(:label, issues: [issue]) }
let(:label2) { create(:label) }
let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } }
let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } }
it "emails subscribers of the issue's added labels only" do
notification.relabeled_issue(issue, [label2], @u_disabled)
should_not_email(subscriber_to_label)
should_email(subscriber_to_label2)
end
it "doesn't send email to anyone but subscribers of the given labels" do
notification.relabeled_issue(issue, [label2], @u_disabled)
should_not_email(issue.assignee)
should_not_email(issue.author)
should_not_email(@u_watcher)
should_not_email(@u_participant_mentioned)
should_not_email(@subscriber)
should_not_email(@watcher_and_subscriber)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(subscriber_to_label)
should_email(subscriber_to_label2)
end
end
describe :close_issue do
it 'should sent email to issue assignee and issue author' do
notification.close_issue(issue, @u_disabled)
......@@ -349,6 +387,15 @@ describe NotificationService, services: true do
should_not_email(@u_participating)
should_not_email(@u_disabled)
end
it "emails subscribers of the merge request's labels" do
subscriber = create(:user)
label = create(:label, merge_requests: [merge_request])
label.toggle_subscription(subscriber)
notification.new_merge_request(merge_request, @u_disabled)
should_email(subscriber)
end
end
describe :reassigned_merge_request do
......@@ -366,6 +413,35 @@ describe NotificationService, services: true do
end
end
describe :relabel_merge_request do
let(:label) { create(:label, merge_requests: [merge_request]) }
let(:label2) { create(:label) }
let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } }
let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } }
it "emails subscribers of the merge request's added labels only" do
notification.relabeled_merge_request(merge_request, [label2], @u_disabled)
should_not_email(subscriber_to_label)
should_email(subscriber_to_label2)
end
it "doesn't send email to anyone but subscribers of the given labels" do
notification.relabeled_merge_request(merge_request, [label2], @u_disabled)
should_not_email(merge_request.assignee)
should_not_email(merge_request.author)
should_not_email(@u_watcher)
should_not_email(@u_participant_mentioned)
should_not_email(@subscriber)
should_not_email(@watcher_and_subscriber)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(subscriber_to_label)
should_email(subscriber_to_label2)
end
end
describe :closed_merge_request do
it do
notification.close_mr(merge_request, @u_disabled)
......@@ -467,16 +543,4 @@ describe NotificationService, services: true do
# Make the watcher a subscriber to detect dupes
issuable.subscriptions.create(user: @watcher_and_subscriber, subscribed: true)
end
def sent_to_user?(user)
ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1
end
def should_email(user)
expect(sent_to_user?(user)).to be_truthy
end
def should_not_email(user)
expect(sent_to_user?(user)).to be_falsey
end
end
require 'spec_helper'
describe Projects::HousekeepingService do
subject { Projects::HousekeepingService.new(project) }
let(:project) { create :project }
describe 'execute' do
before do
project.pushes_since_gc = 3
project.save!
end
it 'enqueues a sidekiq job' do
expect(subject).to receive(:try_obtain_lease).and_return(true)
expect(GitlabShellWorker).to receive(:perform_async).with(:gc, project.path_with_namespace)
subject.execute
expect(project.pushes_since_gc).to eq(0)
end
it 'does not enqueue a job when no lease can be obtained' do
expect(subject).to receive(:try_obtain_lease).and_return(false)
expect(GitlabShellWorker).not_to receive(:perform_async)
expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken)
expect(project.pushes_since_gc).to eq(0)
end
end
describe 'needed?' do
it 'when the count is low enough' do
expect(subject.needed?).to eq(false)
end
it 'when the count is high enough' do
allow(project).to receive(:pushes_since_gc).and_return(10)
expect(subject.needed?).to eq(true)
end
end
describe 'increment!' do
it 'increments the pushes_since_gc counter' do
expect(project.pushes_since_gc).to eq(0)
subject.increment!
expect(project.pushes_since_gc).to eq(1)
end
end
end
......@@ -32,6 +32,7 @@ RSpec.configure do |config|
config.include LoginHelpers, type: :feature
config.include LoginHelpers, type: :request
config.include StubConfiguration
config.include EmailHelpers
config.include RelativeUrl, type: feature
config.include TestEnv
config.include ActiveJob::TestHelper
......
module EmailHelpers
def sent_to_user?(user)
ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1
end
def should_email(user)
expect(sent_to_user?(user)).to be_truthy
end
def should_not_email(user)
expect(sent_to_user?(user)).to be_falsey
end
end
/*!
* Cropper v2.2.5
* https://github.com/fengyuanchen/cropper
*
* Copyright (c) 2014-2016 Fengyuan Chen and contributors
* Released under the MIT license
*
* Date: 2016-01-18T05:42:50.800Z
*/
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node / CommonJS
factory(require('jquery'));
} else {
// Browser globals.
factory(jQuery);
}
})(function ($) {
'use strict';
// Globals
var $window = $(window);
var $document = $(document);
var location = window.location;
var ArrayBuffer = window.ArrayBuffer;
var Uint8Array = window.Uint8Array;
var DataView = window.DataView;
var btoa = window.btoa;
// Constants
var NAMESPACE = 'cropper';
// Classes
var CLASS_MODAL = 'cropper-modal';
var CLASS_HIDE = 'cropper-hide';
var CLASS_HIDDEN = 'cropper-hidden';
var CLASS_INVISIBLE = 'cropper-invisible';
var CLASS_MOVE = 'cropper-move';
var CLASS_CROP = 'cropper-crop';
var CLASS_DISABLED = 'cropper-disabled';
var CLASS_BG = 'cropper-bg';
// Events
var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown';
var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove';
var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel';
var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll';
var EVENT_DBLCLICK = 'dblclick';
var EVENT_LOAD = 'load.' + NAMESPACE;
var EVENT_ERROR = 'error.' + NAMESPACE;
var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace
var EVENT_BUILD = 'build.' + NAMESPACE;
var EVENT_BUILT = 'built.' + NAMESPACE;
var EVENT_CROP_START = 'cropstart.' + NAMESPACE;
var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE;
var EVENT_CROP_END = 'cropend.' + NAMESPACE;
var EVENT_CROP = 'crop.' + NAMESPACE;
var EVENT_ZOOM = 'zoom.' + NAMESPACE;
// RegExps
var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/;
var REGEXP_DATA_URL = /^data\:/;
var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/;
var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/;
// Data keys
var DATA_PREVIEW = 'preview';
var DATA_ACTION = 'action';
// Actions
var ACTION_EAST = 'e';
var ACTION_WEST = 'w';
var ACTION_SOUTH = 's';
var ACTION_NORTH = 'n';
var ACTION_SOUTH_EAST = 'se';
var ACTION_SOUTH_WEST = 'sw';
var ACTION_NORTH_EAST = 'ne';
var ACTION_NORTH_WEST = 'nw';
var ACTION_ALL = 'all';
var ACTION_CROP = 'crop';
var ACTION_MOVE = 'move';
var ACTION_ZOOM = 'zoom';
var ACTION_NONE = 'none';
// Supports
var SUPPORT_CANVAS = $.isFunction($('<canvas>')[0].getContext);
// Maths
var num = Number;
var min = Math.min;
var max = Math.max;
var abs = Math.abs;
var sin = Math.sin;
var cos = Math.cos;
var sqrt = Math.sqrt;
var round = Math.round;
var floor = Math.floor;
// Utilities
var fromCharCode = String.fromCharCode;
function isNumber(n) {
return typeof n === 'number' && !isNaN(n);
}
function isUndefined(n) {
return typeof n === 'undefined';
}
function toArray(obj, offset) {
var args = [];
// This is necessary for IE8
if (isNumber(offset)) {
args.push(offset);
}
return args.slice.apply(obj, args);
}
// Custom proxy to avoid jQuery's guid
function proxy(fn, context) {
var args = toArray(arguments, 2);
return function () {
return fn.apply(context, args.concat(toArray(arguments)));
};
}
function isCrossOriginURL(url) {
var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i);
return parts && (
parts[1] !== location.protocol ||
parts[2] !== location.hostname ||
parts[3] !== location.port
);
}
function addTimestamp(url) {
var timestamp = 'timestamp=' + (new Date()).getTime();
return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp);
}
function getCrossOrigin(crossOrigin) {
return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : '';
}
function getImageSize(image, callback) {
var newImage;
// Modern browsers
if (image.naturalWidth) {
return callback(image.naturalWidth, image.naturalHeight);
}
// IE8: Don't use `new Image()` here (#319)
newImage = document.createElement('img');
newImage.onload = function () {
callback(this.width, this.height);
};
newImage.src = image.src;
}
function getTransform(options) {
var transforms = [];
var rotate = options.rotate;
var scaleX = options.scaleX;
var scaleY = options.scaleY;
if (isNumber(rotate)) {
transforms.push('rotate(' + rotate + 'deg)');
}
if (isNumber(scaleX) && isNumber(scaleY)) {
transforms.push('scale(' + scaleX + ',' + scaleY + ')');
}
return transforms.length ? transforms.join(' ') : 'none';
}
function getRotatedSizes(data, isReversed) {
var deg = abs(data.degree) % 180;
var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180;
var sinArc = sin(arc);
var cosArc = cos(arc);
var width = data.width;
var height = data.height;
var aspectRatio = data.aspectRatio;
var newWidth;
var newHeight;
if (!isReversed) {
newWidth = width * cosArc + height * sinArc;
newHeight = width * sinArc + height * cosArc;
} else {
newWidth = width / (cosArc + sinArc / aspectRatio);
newHeight = newWidth / aspectRatio;
}
return {
width: newWidth,
height: newHeight
};
}
function getSourceCanvas(image, data) {
var canvas = $('<canvas>')[0];
var context = canvas.getContext('2d');
var x = 0;
var y = 0;
var width = data.naturalWidth;
var height = data.naturalHeight;
var rotate = data.rotate;
var scaleX = data.scaleX;
var scaleY = data.scaleY;
var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1);
var rotatable = isNumber(rotate) && rotate !== 0;
var advanced = rotatable || scalable;
var canvasWidth = width;
var canvasHeight = height;
var translateX;
var translateY;
var rotated;
if (scalable) {
translateX = width / 2;
translateY = height / 2;
}
if (rotatable) {
rotated = getRotatedSizes({
width: width,
height: height,
degree: rotate
});
canvasWidth = rotated.width;
canvasHeight = rotated.height;
translateX = rotated.width / 2;
translateY = rotated.height / 2;
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
if (advanced) {
x = -width / 2;
y = -height / 2;
context.save();
context.translate(translateX, translateY);
}
if (rotatable) {
context.rotate(rotate * Math.PI / 180);
}
// Should call `scale` after rotated
if (scalable) {
context.scale(scaleX, scaleY);
}
context.drawImage(image, floor(x), floor(y), floor(width), floor(height));
if (advanced) {
context.restore();
}
return canvas;
}
function getTouchesCenter(touches) {
var length = touches.length;
var pageX = 0;
var pageY = 0;
if (length) {
$.each(touches, function (i, touch) {
pageX += touch.pageX;
pageY += touch.pageY;
});
pageX /= length;
pageY /= length;
}
return {
pageX: pageX,
pageY: pageY
};
}
function getStringFromCharCode(dataView, start, length) {
var str = '';
var i;
for (i = start, length += start; i < length; i++) {
str += fromCharCode(dataView.getUint8(i));
}
return str;
}
function getOrientation(arrayBuffer) {
var dataView = new DataView(arrayBuffer);
var length = dataView.byteLength;
var orientation;
var exifIDCode;
var tiffOffset;
var firstIFDOffset;
var littleEndian;
var endianness;
var app1Start;
var ifdStart;
var offset;
var i;
// Only handle JPEG image (start by 0xFFD8)
if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) {
offset = 2;
while (offset < length) {
if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) {
app1Start = offset;
break;
}
offset++;
}
}
if (app1Start) {
exifIDCode = app1Start + 4;
tiffOffset = app1Start + 10;
if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') {
endianness = dataView.getUint16(tiffOffset);
littleEndian = endianness === 0x4949;
if (littleEndian || endianness === 0x4D4D /* bigEndian */) {
if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) {
firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian);
if (firstIFDOffset >= 0x00000008) {
ifdStart = tiffOffset + firstIFDOffset;
}
}
}
}
}
if (ifdStart) {
length = dataView.getUint16(ifdStart, littleEndian);
for (i = 0; i < length; i++) {
offset = ifdStart + i * 12 + 2;
if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) {
// 8 is the offset of the current tag's value
offset += 8;
// Get the original orientation value
orientation = dataView.getUint16(offset, littleEndian);
// Override the orientation with the default value: 1
dataView.setUint16(offset, 1, littleEndian);
break;
}
}
}
return orientation;
}
function dataURLToArrayBuffer(dataURL) {
var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, '');
var binary = atob(base64);
var length = binary.length;
var arrayBuffer = new ArrayBuffer(length);
var dataView = new Uint8Array(arrayBuffer);
var i;
for (i = 0; i < length; i++) {
dataView[i] = binary.charCodeAt(i);
}
return arrayBuffer;
}
// Only available for JPEG image
function arrayBufferToDataURL(arrayBuffer) {
var dataView = new Uint8Array(arrayBuffer);
var length = dataView.length;
var base64 = '';
var i;
for (i = 0; i < length; i++) {
base64 += fromCharCode(dataView[i]);
}
return 'data:image/jpeg;base64,' + btoa(base64);
}
function Cropper(element, options) {
this.$element = $(element);
this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options);
this.isLoaded = false;
this.isBuilt = false;
this.isCompleted = false;
this.isRotated = false;
this.isCropped = false;
this.isDisabled = false;
this.isReplaced = false;
this.isLimited = false;
this.wheeling = false;
this.isImg = false;
this.originalUrl = '';
this.canvas = null;
this.cropBox = null;
this.init();
}
Cropper.prototype = {
constructor: Cropper,
init: function () {
var $this = this.$element;
var url;
if ($this.is('img')) {
this.isImg = true;
// Should use `$.fn.attr` here. e.g.: "img/picture.jpg"
this.originalUrl = url = $this.attr('src');
// Stop when it's a blank image
if (!url) {
return;
}
// Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg"
url = $this.prop('src');
} else if ($this.is('canvas') && SUPPORT_CANVAS) {
url = $this[0].toDataURL();
}
this.load(url);
},
// A shortcut for triggering custom events
trigger: function (type, data) {
var e = $.Event(type, data);
this.$element.trigger(e);
return e;
},
load: function (url) {
var options = this.options;
var $this = this.$element;
var read;
var xhr;
if (!url) {
return;
}
// Trigger build event first
$this.one(EVENT_BUILD, options.build);
if (this.trigger(EVENT_BUILD).isDefaultPrevented()) {
return;
}
this.url = url;
this.image = {};
if (!options.checkOrientation || !ArrayBuffer) {
return this.clone();
}
read = $.proxy(this.read, this);
// XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari
if (REGEXP_DATA_URL.test(url)) {
return REGEXP_DATA_URL_JPEG.test(url) ?
read(dataURLToArrayBuffer(url)) :
this.clone();
}
xhr = new XMLHttpRequest();
xhr.onerror = xhr.onabort = $.proxy(function () {
this.clone();
}, this);
xhr.onload = function () {
read(this.response);
};
xhr.open('get', url);
xhr.responseType = 'arraybuffer';
xhr.send();
},
read: function (arrayBuffer) {
var options = this.options;
var orientation = getOrientation(arrayBuffer);
var image = this.image;
var rotate;
var scaleX;
var scaleY;
if (orientation > 1) {
this.url = arrayBufferToDataURL(arrayBuffer);
switch (orientation) {
// flip horizontal
case 2:
scaleX = -1;
break;
// rotate left 180°
case 3:
rotate = -180;
break;
// flip vertical
case 4:
scaleY = -1;
break;
// flip vertical + rotate right 90°
case 5:
rotate = 90;
scaleY = -1;
break;
// rotate right 90°
case 6:
rotate = 90;
break;
// flip horizontal + rotate right 90°
case 7:
rotate = 90;
scaleX = -1;
break;
// rotate left 90°
case 8:
rotate = -90;
break;
}
}
if (options.rotatable) {
image.rotate = rotate;
}
if (options.scalable) {
image.scaleX = scaleX;
image.scaleY = scaleY;
}
this.clone();
},
clone: function () {
var options = this.options;
var $this = this.$element;
var url = this.url;
var crossOrigin = '';
var crossOriginUrl;
var $clone;
if (options.checkCrossOrigin && isCrossOriginURL(url)) {
crossOrigin = $this.prop('crossOrigin');
if (crossOrigin) {
crossOriginUrl = url;
} else {
crossOrigin = 'anonymous';
// Bust cache (#148) when there is not a "crossOrigin" property
crossOriginUrl = addTimestamp(url);
}
}
this.crossOrigin = crossOrigin;
this.crossOriginUrl = crossOriginUrl;
this.$clone = $clone = $('<img' + getCrossOrigin(crossOrigin) + ' src="' + (crossOriginUrl || url) + '">');
if (this.isImg) {
if ($this[0].complete) {
this.start();
} else {
$this.one(EVENT_LOAD, $.proxy(this.start, this));
}
} else {
$clone.
one(EVENT_LOAD, $.proxy(this.start, this)).
one(EVENT_ERROR, $.proxy(this.stop, this)).
addClass(CLASS_HIDE).
insertAfter($this);
}
},
start: function () {
var $image = this.$element;
var $clone = this.$clone;
if (!this.isImg) {
$clone.off(EVENT_ERROR, this.stop);
$image = $clone;
}
getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) {
$.extend(this.image, {
naturalWidth: naturalWidth,
naturalHeight: naturalHeight,
aspectRatio: naturalWidth / naturalHeight
});
this.isLoaded = true;
this.build();
}, this));
},
stop: function () {
this.$clone.remove();
this.$clone = null;
},
build: function () {
var options = this.options;
var $this = this.$element;
var $clone = this.$clone;
var $cropper;
var $cropBox;
var $face;
if (!this.isLoaded) {
return;
}
// Unbuild first when replace
if (this.isBuilt) {
this.unbuild();
}
// Create cropper elements
this.$container = $this.parent();
this.$cropper = $cropper = $(Cropper.TEMPLATE);
this.$canvas = $cropper.find('.cropper-canvas').append($clone);
this.$dragBox = $cropper.find('.cropper-drag-box');
this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box');
this.$viewBox = $cropper.find('.cropper-view-box');
this.$face = $face = $cropBox.find('.cropper-face');
// Hide the original image
$this.addClass(CLASS_HIDDEN).after($cropper);
// Show the clone image if is hidden
if (!this.isImg) {
$clone.removeClass(CLASS_HIDE);
}
this.initPreview();
this.bind();
options.aspectRatio = max(0, options.aspectRatio) || NaN;
options.viewMode = max(0, min(3, round(options.viewMode))) || 0;
if (options.autoCrop) {
this.isCropped = true;
if (options.modal) {
this.$dragBox.addClass(CLASS_MODAL);
}
} else {
$cropBox.addClass(CLASS_HIDDEN);
}
if (!options.guides) {
$cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN);
}
if (!options.center) {
$cropBox.find('.cropper-center').addClass(CLASS_HIDDEN);
}
if (options.cropBoxMovable) {
$face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL);
}
if (!options.highlight) {
$face.addClass(CLASS_INVISIBLE);
}
if (options.background) {
$cropper.addClass(CLASS_BG);
}
if (!options.cropBoxResizable) {
$cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN);
}
this.setDragMode(options.dragMode);
this.render();
this.isBuilt = true;
this.setData(options.data);
$this.one(EVENT_BUILT, options.built);
// Trigger the built event asynchronously to keep `data('cropper')` is defined
setTimeout($.proxy(function () {
this.trigger(EVENT_BUILT);
this.isCompleted = true;
}, this), 0);
},
unbuild: function () {
if (!this.isBuilt) {
return;
}
this.isBuilt = false;
this.isCompleted = false;
this.initialImage = null;
// Clear `initialCanvas` is necessary when replace
this.initialCanvas = null;
this.initialCropBox = null;
this.container = null;
this.canvas = null;
// Clear `cropBox` is necessary when replace
this.cropBox = null;
this.unbind();
this.resetPreview();
this.$preview = null;
this.$viewBox = null;
this.$cropBox = null;
this.$dragBox = null;
this.$canvas = null;
this.$container = null;
this.$cropper.remove();
this.$cropper = null;
},
render: function () {
this.initContainer();
this.initCanvas();
this.initCropBox();
this.renderCanvas();
if (this.isCropped) {
this.renderCropBox();
}
},
initContainer: function () {
var options = this.options;
var $this = this.$element;
var $container = this.$container;
var $cropper = this.$cropper;
$cropper.addClass(CLASS_HIDDEN);
$this.removeClass(CLASS_HIDDEN);
$cropper.css((this.container = {
width: max($container.width(), num(options.minContainerWidth) || 200),
height: max($container.height(), num(options.minContainerHeight) || 100)
}));
$this.addClass(CLASS_HIDDEN);
$cropper.removeClass(CLASS_HIDDEN);
},
// Canvas (image wrapper)
initCanvas: function () {
var viewMode = this.options.viewMode;
var container = this.container;
var containerWidth = container.width;
var containerHeight = container.height;
var image = this.image;
var imageNaturalWidth = image.naturalWidth;
var imageNaturalHeight = image.naturalHeight;
var is90Degree = abs(image.rotate) === 90;
var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth;
var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight;
var aspectRatio = naturalWidth / naturalHeight;
var canvasWidth = containerWidth;
var canvasHeight = containerHeight;
var canvas;
if (containerHeight * aspectRatio > containerWidth) {
if (viewMode === 3) {
canvasWidth = containerHeight * aspectRatio;
} else {
canvasHeight = containerWidth / aspectRatio;
}
} else {
if (viewMode === 3) {
canvasHeight = containerWidth / aspectRatio;
} else {
canvasWidth = containerHeight * aspectRatio;
}
}
canvas = {
naturalWidth: naturalWidth,
naturalHeight: naturalHeight,
aspectRatio: aspectRatio,
width: canvasWidth,
height: canvasHeight
};
canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2;
canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2;
this.canvas = canvas;
this.isLimited = (viewMode === 1 || viewMode === 2);
this.limitCanvas(true, true);
this.initialImage = $.extend({}, image);
this.initialCanvas = $.extend({}, canvas);
},
limitCanvas: function (isSizeLimited, isPositionLimited) {
var options = this.options;
var viewMode = options.viewMode;
var container = this.container;
var containerWidth = container.width;
var containerHeight = container.height;
var canvas = this.canvas;
var aspectRatio = canvas.aspectRatio;
var cropBox = this.cropBox;
var isCropped = this.isCropped && cropBox;
var minCanvasWidth;
var minCanvasHeight;
var newCanvasLeft;
var newCanvasTop;
if (isSizeLimited) {
minCanvasWidth = num(options.minCanvasWidth) || 0;
minCanvasHeight = num(options.minCanvasHeight) || 0;
if (viewMode) {
if (viewMode > 1) {
minCanvasWidth = max(minCanvasWidth, containerWidth);
minCanvasHeight = max(minCanvasHeight, containerHeight);
if (viewMode === 3) {
if (minCanvasHeight * aspectRatio > minCanvasWidth) {
minCanvasWidth = minCanvasHeight * aspectRatio;
} else {
minCanvasHeight = minCanvasWidth / aspectRatio;
}
}
} else {
if (minCanvasWidth) {
minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0);
} else if (minCanvasHeight) {
minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0);
} else if (isCropped) {
minCanvasWidth = cropBox.width;
minCanvasHeight = cropBox.height;
if (minCanvasHeight * aspectRatio > minCanvasWidth) {
minCanvasWidth = minCanvasHeight * aspectRatio;
} else {
minCanvasHeight = minCanvasWidth / aspectRatio;
}
}
}
}
if (minCanvasWidth && minCanvasHeight) {
if (minCanvasHeight * aspectRatio > minCanvasWidth) {
minCanvasHeight = minCanvasWidth / aspectRatio;
} else {
minCanvasWidth = minCanvasHeight * aspectRatio;
}
} else if (minCanvasWidth) {
minCanvasHeight = minCanvasWidth / aspectRatio;
} else if (minCanvasHeight) {
minCanvasWidth = minCanvasHeight * aspectRatio;
}
canvas.minWidth = minCanvasWidth;
canvas.minHeight = minCanvasHeight;
canvas.maxWidth = Infinity;
canvas.maxHeight = Infinity;
}
if (isPositionLimited) {
if (viewMode) {
newCanvasLeft = containerWidth - canvas.width;
newCanvasTop = containerHeight - canvas.height;
canvas.minLeft = min(0, newCanvasLeft);
canvas.minTop = min(0, newCanvasTop);
canvas.maxLeft = max(0, newCanvasLeft);
canvas.maxTop = max(0, newCanvasTop);
if (isCropped && this.isLimited) {
canvas.minLeft = min(
cropBox.left,
cropBox.left + cropBox.width - canvas.width
);
canvas.minTop = min(
cropBox.top,
cropBox.top + cropBox.height - canvas.height
);
canvas.maxLeft = cropBox.left;
canvas.maxTop = cropBox.top;
if (viewMode === 2) {
if (canvas.width >= containerWidth) {
canvas.minLeft = min(0, newCanvasLeft);
canvas.maxLeft = max(0, newCanvasLeft);
}
if (canvas.height >= containerHeight) {
canvas.minTop = min(0, newCanvasTop);
canvas.maxTop = max(0, newCanvasTop);
}
}
}
} else {
canvas.minLeft = -canvas.width;
canvas.minTop = -canvas.height;
canvas.maxLeft = containerWidth;
canvas.maxTop = containerHeight;
}
}
},
renderCanvas: function (isChanged) {
var canvas = this.canvas;
var image = this.image;
var rotate = image.rotate;
var naturalWidth = image.naturalWidth;
var naturalHeight = image.naturalHeight;
var aspectRatio;
var rotated;
if (this.isRotated) {
this.isRotated = false;
// Computes rotated sizes with image sizes
rotated = getRotatedSizes({
width: image.width,
height: image.height,
degree: rotate
});
aspectRatio = rotated.width / rotated.height;
if (aspectRatio !== canvas.aspectRatio) {
canvas.left -= (rotated.width - canvas.width) / 2;
canvas.top -= (rotated.height - canvas.height) / 2;
canvas.width = rotated.width;
canvas.height = rotated.height;
canvas.aspectRatio = aspectRatio;
canvas.naturalWidth = naturalWidth;
canvas.naturalHeight = naturalHeight;
// Computes rotated sizes with natural image sizes
if (rotate % 180) {
rotated = getRotatedSizes({
width: naturalWidth,
height: naturalHeight,
degree: rotate
});
canvas.naturalWidth = rotated.width;
canvas.naturalHeight = rotated.height;
}
this.limitCanvas(true, false);
}
}
if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) {
canvas.left = canvas.oldLeft;
}
if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) {
canvas.top = canvas.oldTop;
}
canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth);
canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight);
this.limitCanvas(false, true);
canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft);
canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop);
this.$canvas.css({
width: canvas.width,
height: canvas.height,
left: canvas.left,
top: canvas.top
});
this.renderImage();
if (this.isCropped && this.isLimited) {
this.limitCropBox(true, true);
}
if (isChanged) {
this.output();
}
},
renderImage: function (isChanged) {
var canvas = this.canvas;
var image = this.image;
var reversed;
if (image.rotate) {
reversed = getRotatedSizes({
width: canvas.width,
height: canvas.height,
degree: image.rotate,
aspectRatio: image.aspectRatio
}, true);
}
$.extend(image, reversed ? {
width: reversed.width,
height: reversed.height,
left: (canvas.width - reversed.width) / 2,
top: (canvas.height - reversed.height) / 2
} : {
width: canvas.width,
height: canvas.height,
left: 0,
top: 0
});
this.$clone.css({
width: image.width,
height: image.height,
marginLeft: image.left,
marginTop: image.top,
transform: getTransform(image)
});
if (isChanged) {
this.output();
}
},
initCropBox: function () {
var options = this.options;
var canvas = this.canvas;
var aspectRatio = options.aspectRatio;
var autoCropArea = num(options.autoCropArea) || 0.8;
var cropBox = {
width: canvas.width,
height: canvas.height
};
if (aspectRatio) {
if (canvas.height * aspectRatio > canvas.width) {
cropBox.height = cropBox.width / aspectRatio;
} else {
cropBox.width = cropBox.height * aspectRatio;
}
}
this.cropBox = cropBox;
this.limitCropBox(true, true);
// Initialize auto crop area
cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth);
cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight);
// The width of auto crop area must large than "minWidth", and the height too. (#164)
cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea);
cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea);
cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2;
cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2;
this.initialCropBox = $.extend({}, cropBox);
},
limitCropBox: function (isSizeLimited, isPositionLimited) {
var options = this.options;
var aspectRatio = options.aspectRatio;
var container = this.container;
var containerWidth = container.width;
var containerHeight = container.height;
var canvas = this.canvas;
var cropBox = this.cropBox;
var isLimited = this.isLimited;
var minCropBoxWidth;
var minCropBoxHeight;
var maxCropBoxWidth;
var maxCropBoxHeight;
if (isSizeLimited) {
minCropBoxWidth = num(options.minCropBoxWidth) || 0;
minCropBoxHeight = num(options.minCropBoxHeight) || 0;
// The min/maxCropBoxWidth/Height must be less than containerWidth/Height
minCropBoxWidth = min(minCropBoxWidth, containerWidth);
minCropBoxHeight = min(minCropBoxHeight, containerHeight);
maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth);
maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight);
if (aspectRatio) {
if (minCropBoxWidth && minCropBoxHeight) {
if (minCropBoxHeight * aspectRatio > minCropBoxWidth) {
minCropBoxHeight = minCropBoxWidth / aspectRatio;
} else {
minCropBoxWidth = minCropBoxHeight * aspectRatio;
}
} else if (minCropBoxWidth) {
minCropBoxHeight = minCropBoxWidth / aspectRatio;
} else if (minCropBoxHeight) {
minCropBoxWidth = minCropBoxHeight * aspectRatio;
}
if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) {
maxCropBoxHeight = maxCropBoxWidth / aspectRatio;
} else {
maxCropBoxWidth = maxCropBoxHeight * aspectRatio;
}
}
// The minWidth/Height must be less than maxWidth/Height
cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth);
cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight);
cropBox.maxWidth = maxCropBoxWidth;
cropBox.maxHeight = maxCropBoxHeight;
}
if (isPositionLimited) {
if (isLimited) {
cropBox.minLeft = max(0, canvas.left);
cropBox.minTop = max(0, canvas.top);
cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width;
cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height;
} else {
cropBox.minLeft = 0;
cropBox.minTop = 0;
cropBox.maxLeft = containerWidth - cropBox.width;
cropBox.maxTop = containerHeight - cropBox.height;
}
}
},
renderCropBox: function () {
var options = this.options;
var container = this.container;
var containerWidth = container.width;
var containerHeight = container.height;
var cropBox = this.cropBox;
if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) {
cropBox.left = cropBox.oldLeft;
}
if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) {
cropBox.top = cropBox.oldTop;
}
cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth);
cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight);
this.limitCropBox(false, true);
cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft);
cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop);
if (options.movable && options.cropBoxMovable) {
// Turn to move the canvas when the crop box is equal to the container
this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL);
}
this.$cropBox.css({
width: cropBox.width,
height: cropBox.height,
left: cropBox.left,
top: cropBox.top
});
if (this.isCropped && this.isLimited) {
this.limitCanvas(true, true);
}
if (!this.isDisabled) {
this.output();
}
},
output: function () {
this.preview();
if (this.isCompleted) {
this.trigger(EVENT_CROP, this.getData());
} else if (!this.isBuilt) {
// Only trigger one crop event before complete
this.$element.one(EVENT_BUILT, $.proxy(function () {
this.trigger(EVENT_CROP, this.getData());
}, this));
}
},
initPreview: function () {
var crossOrigin = getCrossOrigin(this.crossOrigin);
var url = crossOrigin ? this.crossOriginUrl : this.url;
this.$preview = $(this.options.preview);
this.$viewBox.html('<img' + crossOrigin + ' src="' + url + '">');
this.$preview.each(function () {
var $this = $(this);
// Save the original size for recover
$this.data(DATA_PREVIEW, {
width: $this.width(),
height: $this.height(),
html: $this.html()
});
/**
* Override img element styles
* Add `display:block` to avoid margin top issue
* (Occur only when margin-top <= -height)
*/
$this.html(
'<img' + crossOrigin + ' src="' + url + '" style="' +
'display:block;width:100%;height:auto;' +
'min-width:0!important;min-height:0!important;' +
'max-width:none!important;max-height:none!important;' +
'image-orientation:0deg!important;">'
);
});
},
resetPreview: function () {
this.$preview.each(function () {
var $this = $(this);
var data = $this.data(DATA_PREVIEW);
$this.css({
width: data.width,
height: data.height
}).html(data.html).removeData(DATA_PREVIEW);
});
},
preview: function () {
var image = this.image;
var canvas = this.canvas;
var cropBox = this.cropBox;
var cropBoxWidth = cropBox.width;
var cropBoxHeight = cropBox.height;
var width = image.width;
var height = image.height;
var left = cropBox.left - canvas.left - image.left;
var top = cropBox.top - canvas.top - image.top;
if (!this.isCropped || this.isDisabled) {
return;
}
this.$viewBox.find('img').css({
width: width,
height: height,
marginLeft: -left,
marginTop: -top,
transform: getTransform(image)
});
this.$preview.each(function () {
var $this = $(this);
var data = $this.data(DATA_PREVIEW);
var originalWidth = data.width;
var originalHeight = data.height;
var newWidth = originalWidth;
var newHeight = originalHeight;
var ratio = 1;
if (cropBoxWidth) {
ratio = originalWidth / cropBoxWidth;
newHeight = cropBoxHeight * ratio;
}
if (cropBoxHeight && newHeight > originalHeight) {
ratio = originalHeight / cropBoxHeight;
newWidth = cropBoxWidth * ratio;
newHeight = originalHeight;
}
$this.css({
width: newWidth,
height: newHeight
}).find('img').css({
width: width * ratio,
height: height * ratio,
marginLeft: -left * ratio,
marginTop: -top * ratio,
transform: getTransform(image)
});
});
},
bind: function () {
var options = this.options;
var $this = this.$element;
var $cropper = this.$cropper;
if ($.isFunction(options.cropstart)) {
$this.on(EVENT_CROP_START, options.cropstart);
}
if ($.isFunction(options.cropmove)) {
$this.on(EVENT_CROP_MOVE, options.cropmove);
}
if ($.isFunction(options.cropend)) {
$this.on(EVENT_CROP_END, options.cropend);
}
if ($.isFunction(options.crop)) {
$this.on(EVENT_CROP, options.crop);
}
if ($.isFunction(options.zoom)) {
$this.on(EVENT_ZOOM, options.zoom);
}
$cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this));
if (options.zoomable && options.zoomOnWheel) {
$cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this));
}
if (options.toggleDragModeOnDblclick) {
$cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this));
}
$document.
on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))).
on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this)));
if (options.responsive) {
$window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this)));
}
},
unbind: function () {
var options = this.options;
var $this = this.$element;
var $cropper = this.$cropper;
if ($.isFunction(options.cropstart)) {
$this.off(EVENT_CROP_START, options.cropstart);
}
if ($.isFunction(options.cropmove)) {
$this.off(EVENT_CROP_MOVE, options.cropmove);
}
if ($.isFunction(options.cropend)) {
$this.off(EVENT_CROP_END, options.cropend);
}
if ($.isFunction(options.crop)) {
$this.off(EVENT_CROP, options.crop);
}
if ($.isFunction(options.zoom)) {
$this.off(EVENT_ZOOM, options.zoom);
}
$cropper.off(EVENT_MOUSE_DOWN, this.cropStart);
if (options.zoomable && options.zoomOnWheel) {
$cropper.off(EVENT_WHEEL, this.wheel);
}
if (options.toggleDragModeOnDblclick) {
$cropper.off(EVENT_DBLCLICK, this.dblclick);
}
$document.
off(EVENT_MOUSE_MOVE, this._cropMove).
off(EVENT_MOUSE_UP, this._cropEnd);
if (options.responsive) {
$window.off(EVENT_RESIZE, this._resize);
}
},
resize: function () {
var restore = this.options.restore;
var $container = this.$container;
var container = this.container;
var canvasData;
var cropBoxData;
var ratio;
// Check `container` is necessary for IE8
if (this.isDisabled || !container) {
return;
}
ratio = $container.width() / container.width;
// Resize when width changed or height changed
if (ratio !== 1 || $container.height() !== container.height) {
if (restore) {
canvasData = this.getCanvasData();
cropBoxData = this.getCropBoxData();
}
this.render();
if (restore) {
this.setCanvasData($.each(canvasData, function (i, n) {
canvasData[i] = n * ratio;
}));
this.setCropBoxData($.each(cropBoxData, function (i, n) {
cropBoxData[i] = n * ratio;
}));
}
}
},
dblclick: function () {
if (this.isDisabled) {
return;
}
if (this.$dragBox.hasClass(CLASS_CROP)) {
this.setDragMode(ACTION_MOVE);
} else {
this.setDragMode(ACTION_CROP);
}
},
wheel: function (event) {
var e = event.originalEvent || event;
var ratio = num(this.options.wheelZoomRatio) || 0.1;
var delta = 1;
if (this.isDisabled) {
return;
}
event.preventDefault();
// Limit wheel speed to prevent zoom too fast
if (this.wheeling) {
return;
}
this.wheeling = true;
setTimeout($.proxy(function () {
this.wheeling = false;
}, this), 50);
if (e.deltaY) {
delta = e.deltaY > 0 ? 1 : -1;
} else if (e.wheelDelta) {
delta = -e.wheelDelta / 120;
} else if (e.detail) {
delta = e.detail > 0 ? 1 : -1;
}
this.zoom(-delta * ratio, event);
},
cropStart: function (event) {
var options = this.options;
var originalEvent = event.originalEvent;
var touches = originalEvent && originalEvent.touches;
var e = event;
var touchesLength;
var action;
if (this.isDisabled) {
return;
}
if (touches) {
touchesLength = touches.length;
if (touchesLength > 1) {
if (options.zoomable && options.zoomOnTouch && touchesLength === 2) {
e = touches[1];
this.startX2 = e.pageX;
this.startY2 = e.pageY;
action = ACTION_ZOOM;
} else {
return;
}
}
e = touches[0];
}
action = action || $(e.target).data(DATA_ACTION);
if (REGEXP_ACTIONS.test(action)) {
if (this.trigger(EVENT_CROP_START, {
originalEvent: originalEvent,
action: action
}).isDefaultPrevented()) {
return;
}
event.preventDefault();
this.action = action;
this.cropping = false;
// IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y`
// IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y`
this.startX = e.pageX || originalEvent && originalEvent.pageX;
this.startY = e.pageY || originalEvent && originalEvent.pageY;
if (action === ACTION_CROP) {
this.cropping = true;
this.$dragBox.addClass(CLASS_MODAL);
}
}
},
cropMove: function (event) {
var options = this.options;
var originalEvent = event.originalEvent;
var touches = originalEvent && originalEvent.touches;
var e = event;
var action = this.action;
var touchesLength;
if (this.isDisabled) {
return;
}
if (touches) {
touchesLength = touches.length;
if (touchesLength > 1) {
if (options.zoomable && options.zoomOnTouch && touchesLength === 2) {
e = touches[1];
this.endX2 = e.pageX;
this.endY2 = e.pageY;
} else {
return;
}
}
e = touches[0];
}
if (action) {
if (this.trigger(EVENT_CROP_MOVE, {
originalEvent: originalEvent,
action: action
}).isDefaultPrevented()) {
return;
}
event.preventDefault();
this.endX = e.pageX || originalEvent && originalEvent.pageX;
this.endY = e.pageY || originalEvent && originalEvent.pageY;
this.change(e.shiftKey, action === ACTION_ZOOM ? event : null);
}
},
cropEnd: function (event) {
var originalEvent = event.originalEvent;
var action = this.action;
if (this.isDisabled) {
return;
}
if (action) {
event.preventDefault();
if (this.cropping) {
this.cropping = false;
this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal);
}
this.action = '';
this.trigger(EVENT_CROP_END, {
originalEvent: originalEvent,
action: action
});
}
},
change: function (shiftKey, event) {
var options = this.options;
var aspectRatio = options.aspectRatio;
var action = this.action;
var container = this.container;
var canvas = this.canvas;
var cropBox = this.cropBox;
var width = cropBox.width;
var height = cropBox.height;
var left = cropBox.left;
var top = cropBox.top;
var right = left + width;
var bottom = top + height;
var minLeft = 0;
var minTop = 0;
var maxWidth = container.width;
var maxHeight = container.height;
var renderable = true;
var offset;
var range;
// Locking aspect ratio in "free mode" by holding shift key (#259)
if (!aspectRatio && shiftKey) {
aspectRatio = width && height ? width / height : 1;
}
if (this.limited) {
minLeft = cropBox.minLeft;
minTop = cropBox.minTop;
maxWidth = minLeft + min(container.width, canvas.width);
maxHeight = minTop + min(container.height, canvas.height);
}
range = {
x: this.endX - this.startX,
y: this.endY - this.startY
};
if (aspectRatio) {
range.X = range.y * aspectRatio;
range.Y = range.x / aspectRatio;
}
switch (action) {
// Move crop box
case ACTION_ALL:
left += range.x;
top += range.y;
break;
// Resize crop box
case ACTION_EAST:
if (range.x >= 0 && (right >= maxWidth || aspectRatio &&
(top <= minTop || bottom >= maxHeight))) {
renderable = false;
break;
}
width += range.x;
if (aspectRatio) {
height = width / aspectRatio;
top -= range.Y / 2;
}
if (width < 0) {
action = ACTION_WEST;
width = 0;
}
break;
case ACTION_NORTH:
if (range.y <= 0 && (top <= minTop || aspectRatio &&
(left <= minLeft || right >= maxWidth))) {
renderable = false;
break;
}
height -= range.y;
top += range.y;
if (aspectRatio) {
width = height * aspectRatio;
left += range.X / 2;
}
if (height < 0) {
action = ACTION_SOUTH;
height = 0;
}
break;
case ACTION_WEST:
if (range.x <= 0 && (left <= minLeft || aspectRatio &&
(top <= minTop || bottom >= maxHeight))) {
renderable = false;
break;
}
width -= range.x;
left += range.x;
if (aspectRatio) {
height = width / aspectRatio;
top += range.Y / 2;
}
if (width < 0) {
action = ACTION_EAST;
width = 0;
}
break;
case ACTION_SOUTH:
if (range.y >= 0 && (bottom >= maxHeight || aspectRatio &&
(left <= minLeft || right >= maxWidth))) {
renderable = false;
break;
}
height += range.y;
if (aspectRatio) {
width = height * aspectRatio;
left -= range.X / 2;
}
if (height < 0) {
action = ACTION_NORTH;
height = 0;
}
break;
case ACTION_NORTH_EAST:
if (aspectRatio) {
if (range.y <= 0 && (top <= minTop || right >= maxWidth)) {
renderable = false;
break;
}
height -= range.y;
top += range.y;
width = height * aspectRatio;
} else {
if (range.x >= 0) {
if (right < maxWidth) {
width += range.x;
} else if (range.y <= 0 && top <= minTop) {
renderable = false;
}
} else {
width += range.x;
}
if (range.y <= 0) {
if (top > minTop) {
height -= range.y;
top += range.y;
}
} else {
height -= range.y;
top += range.y;
}
}
if (width < 0 && height < 0) {
action = ACTION_SOUTH_WEST;
height = 0;
width = 0;
} else if (width < 0) {
action = ACTION_NORTH_WEST;
width = 0;
} else if (height < 0) {
action = ACTION_SOUTH_EAST;
height = 0;
}
break;
case ACTION_NORTH_WEST:
if (aspectRatio) {
if (range.y <= 0 && (top <= minTop || left <= minLeft)) {
renderable = false;
break;
}
height -= range.y;
top += range.y;
width = height * aspectRatio;
left += range.X;
} else {
if (range.x <= 0) {
if (left > minLeft) {
width -= range.x;
left += range.x;
} else if (range.y <= 0 && top <= minTop) {
renderable = false;
}
} else {
width -= range.x;
left += range.x;
}
if (range.y <= 0) {
if (top > minTop) {
height -= range.y;
top += range.y;
}
} else {
height -= range.y;
top += range.y;
}
}
if (width < 0 && height < 0) {
action = ACTION_SOUTH_EAST;
height = 0;
width = 0;
} else if (width < 0) {
action = ACTION_NORTH_EAST;
width = 0;
} else if (height < 0) {
action = ACTION_SOUTH_WEST;
height = 0;
}
break;
case ACTION_SOUTH_WEST:
if (aspectRatio) {
if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) {
renderable = false;
break;
}
width -= range.x;
left += range.x;
height = width / aspectRatio;
} else {
if (range.x <= 0) {
if (left > minLeft) {
width -= range.x;
left += range.x;
} else if (range.y >= 0 && bottom >= maxHeight) {
renderable = false;
}
} else {
width -= range.x;
left += range.x;
}
if (range.y >= 0) {
if (bottom < maxHeight) {
height += range.y;
}
} else {
height += range.y;
}
}
if (width < 0 && height < 0) {
action = ACTION_NORTH_EAST;
height = 0;
width = 0;
} else if (width < 0) {
action = ACTION_SOUTH_EAST;
width = 0;
} else if (height < 0) {
action = ACTION_NORTH_WEST;
height = 0;
}
break;
case ACTION_SOUTH_EAST:
if (aspectRatio) {
if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) {
renderable = false;
break;
}
width += range.x;
height = width / aspectRatio;
} else {
if (range.x >= 0) {
if (right < maxWidth) {
width += range.x;
} else if (range.y >= 0 && bottom >= maxHeight) {
renderable = false;
}
} else {
width += range.x;
}
if (range.y >= 0) {
if (bottom < maxHeight) {
height += range.y;
}
} else {
height += range.y;
}
}
if (width < 0 && height < 0) {
action = ACTION_NORTH_WEST;
height = 0;
width = 0;
} else if (width < 0) {
action = ACTION_SOUTH_WEST;
width = 0;
} else if (height < 0) {
action = ACTION_NORTH_EAST;
height = 0;
}
break;
// Move canvas
case ACTION_MOVE:
this.move(range.x, range.y);
renderable = false;
break;
// Zoom canvas
case ACTION_ZOOM:
this.zoom((function (x1, y1, x2, y2) {
var z1 = sqrt(x1 * x1 + y1 * y1);
var z2 = sqrt(x2 * x2 + y2 * y2);
return (z2 - z1) / z1;
})(
abs(this.startX - this.startX2),
abs(this.startY - this.startY2),
abs(this.endX - this.endX2),
abs(this.endY - this.endY2)
), event);
this.startX2 = this.endX2;
this.startY2 = this.endY2;
renderable = false;
break;
// Create crop box
case ACTION_CROP:
if (!range.x || !range.y) {
renderable = false;
break;
}
offset = this.$cropper.offset();
left = this.startX - offset.left;
top = this.startY - offset.top;
width = cropBox.minWidth;
height = cropBox.minHeight;
if (range.x > 0) {
action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST;
} else if (range.x < 0) {
left -= width;
action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST;
}
if (range.y < 0) {
top -= height;
}
// Show the crop box if is hidden
if (!this.isCropped) {
this.$cropBox.removeClass(CLASS_HIDDEN);
this.isCropped = true;
if (this.limited) {
this.limitCropBox(true, true);
}
}
break;
// No default
}
if (renderable) {
cropBox.width = width;
cropBox.height = height;
cropBox.left = left;
cropBox.top = top;
this.action = action;
this.renderCropBox();
}
// Override
this.startX = this.endX;
this.startY = this.endY;
},
// Show the crop box manually
crop: function () {
if (!this.isBuilt || this.isDisabled) {
return;
}
if (!this.isCropped) {
this.isCropped = true;
this.limitCropBox(true, true);
if (this.options.modal) {
this.$dragBox.addClass(CLASS_MODAL);
}
this.$cropBox.removeClass(CLASS_HIDDEN);
}
this.setCropBoxData(this.initialCropBox);
},
// Reset the image and crop box to their initial states
reset: function () {
if (!this.isBuilt || this.isDisabled) {
return;
}
this.image = $.extend({}, this.initialImage);
this.canvas = $.extend({}, this.initialCanvas);
this.cropBox = $.extend({}, this.initialCropBox);
this.renderCanvas();
if (this.isCropped) {
this.renderCropBox();
}
},
// Clear the crop box
clear: function () {
if (!this.isCropped || this.isDisabled) {
return;
}
$.extend(this.cropBox, {
left: 0,
top: 0,
width: 0,
height: 0
});
this.isCropped = false;
this.renderCropBox();
this.limitCanvas(true, true);
// Render canvas after crop box rendered
this.renderCanvas();
this.$dragBox.removeClass(CLASS_MODAL);
this.$cropBox.addClass(CLASS_HIDDEN);
},
/**
* Replace the image's src and rebuild the cropper
*
* @param {String} url
*/
replace: function (url) {
if (!this.isDisabled && url) {
if (this.isImg) {
this.isReplaced = true;
this.$element.attr('src', url);
}
// Clear previous data
this.options.data = null;
this.load(url);
}
},
// Enable (unfreeze) the cropper
enable: function () {
if (this.isBuilt) {
this.isDisabled = false;
this.$cropper.removeClass(CLASS_DISABLED);
}
},
// Disable (freeze) the cropper
disable: function () {
if (this.isBuilt) {
this.isDisabled = true;
this.$cropper.addClass(CLASS_DISABLED);
}
},
// Destroy the cropper and remove the instance from the image
destroy: function () {
var $this = this.$element;
if (this.isLoaded) {
if (this.isImg && this.isReplaced) {
$this.attr('src', this.originalUrl);
}
this.unbuild();
$this.removeClass(CLASS_HIDDEN);
} else {
if (this.isImg) {
$this.off(EVENT_LOAD, this.start);
} else if (this.$clone) {
this.$clone.remove();
}
}
$this.removeData(NAMESPACE);
},
/**
* Move the canvas with relative offsets
*
* @param {Number} offsetX
* @param {Number} offsetY (optional)
*/
move: function (offsetX, offsetY) {
var canvas = this.canvas;
this.moveTo(
isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX),
isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY)
);
},
/**
* Move the canvas to an absolute point
*
* @param {Number} x
* @param {Number} y (optional)
*/
moveTo: function (x, y) {
var canvas = this.canvas;
var isChanged = false;
// If "y" is not present, its default value is "x"
if (isUndefined(y)) {
y = x;
}
x = num(x);
y = num(y);
if (this.isBuilt && !this.isDisabled && this.options.movable) {
if (isNumber(x)) {
canvas.left = x;
isChanged = true;
}
if (isNumber(y)) {
canvas.top = y;
isChanged = true;
}
if (isChanged) {
this.renderCanvas(true);
}
}
},
/**
* Zoom the canvas with a relative ratio
*
* @param {Number} ratio
* @param {jQuery Event} _event (private)
*/
zoom: function (ratio, _event) {
var canvas = this.canvas;
ratio = num(ratio);
if (ratio < 0) {
ratio = 1 / (1 - ratio);
} else {
ratio = 1 + ratio;
}
this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event);
},
/**
* Zoom the canvas to an absolute ratio
*
* @param {Number} ratio
* @param {jQuery Event} _event (private)
*/
zoomTo: function (ratio, _event) {
var options = this.options;
var canvas = this.canvas;
var width = canvas.width;
var height = canvas.height;
var naturalWidth = canvas.naturalWidth;
var naturalHeight = canvas.naturalHeight;
var originalEvent;
var newWidth;
var newHeight;
var offset;
var center;
ratio = num(ratio);
if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) {
newWidth = naturalWidth * ratio;
newHeight = naturalHeight * ratio;
if (_event) {
originalEvent = _event.originalEvent;
}
if (this.trigger(EVENT_ZOOM, {
originalEvent: originalEvent,
oldRatio: width / naturalWidth,
ratio: newWidth / naturalWidth
}).isDefaultPrevented()) {
return;
}
if (originalEvent) {
offset = this.$cropper.offset();
center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : {
pageX: _event.pageX || originalEvent.pageX || 0,
pageY: _event.pageY || originalEvent.pageY || 0
};
// Zoom from the triggering point of the event
canvas.left -= (newWidth - width) * (
((center.pageX - offset.left) - canvas.left) / width
);
canvas.top -= (newHeight - height) * (
((center.pageY - offset.top) - canvas.top) / height
);
} else {
// Zoom from the center of the canvas
canvas.left -= (newWidth - width) / 2;
canvas.top -= (newHeight - height) / 2;
}
canvas.width = newWidth;
canvas.height = newHeight;
this.renderCanvas(true);
}
},
/**
* Rotate the canvas with a relative degree
*
* @param {Number} degree
*/
rotate: function (degree) {
this.rotateTo((this.image.rotate || 0) + num(degree));
},
/**
* Rotate the canvas to an absolute degree
* https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate()
*
* @param {Number} degree
*/
rotateTo: function (degree) {
degree = num(degree);
if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) {
this.image.rotate = degree % 360;
this.isRotated = true;
this.renderCanvas(true);
}
},
/**
* Scale the image
* https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale()
*
* @param {Number} scaleX
* @param {Number} scaleY (optional)
*/
scale: function (scaleX, scaleY) {
var image = this.image;
var isChanged = false;
// If "scaleY" is not present, its default value is "scaleX"
if (isUndefined(scaleY)) {
scaleY = scaleX;
}
scaleX = num(scaleX);
scaleY = num(scaleY);
if (this.isBuilt && !this.isDisabled && this.options.scalable) {
if (isNumber(scaleX)) {
image.scaleX = scaleX;
isChanged = true;
}
if (isNumber(scaleY)) {
image.scaleY = scaleY;
isChanged = true;
}
if (isChanged) {
this.renderImage(true);
}
}
},
/**
* Scale the abscissa of the image
*
* @param {Number} scaleX
*/
scaleX: function (scaleX) {
var scaleY = this.image.scaleY;
this.scale(scaleX, isNumber(scaleY) ? scaleY : 1);
},
/**
* Scale the ordinate of the image
*
* @param {Number} scaleY
*/
scaleY: function (scaleY) {
var scaleX = this.image.scaleX;
this.scale(isNumber(scaleX) ? scaleX : 1, scaleY);
},
/**
* Get the cropped area position and size data (base on the original image)
*
* @param {Boolean} isRounded (optional)
* @return {Object} data
*/
getData: function (isRounded) {
var options = this.options;
var image = this.image;
var canvas = this.canvas;
var cropBox = this.cropBox;
var ratio;
var data;
if (this.isBuilt && this.isCropped) {
data = {
x: cropBox.left - canvas.left,
y: cropBox.top - canvas.top,
width: cropBox.width,
height: cropBox.height
};
ratio = image.width / image.naturalWidth;
$.each(data, function (i, n) {
n = n / ratio;
data[i] = isRounded ? round(n) : n;
});
} else {
data = {
x: 0,
y: 0,
width: 0,
height: 0
};
}
if (options.rotatable) {
data.rotate = image.rotate || 0;
}
if (options.scalable) {
data.scaleX = image.scaleX || 1;
data.scaleY = image.scaleY || 1;
}
return data;
},
/**
* Set the cropped area position and size with new data
*
* @param {Object} data
*/
setData: function (data) {
var options = this.options;
var image = this.image;
var canvas = this.canvas;
var cropBoxData = {};
var isRotated;
var isScaled;
var ratio;
if ($.isFunction(data)) {
data = data.call(this.element);
}
if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) {
if (options.rotatable) {
if (isNumber(data.rotate) && data.rotate !== image.rotate) {
image.rotate = data.rotate;
this.isRotated = isRotated = true;
}
}
if (options.scalable) {
if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) {
image.scaleX = data.scaleX;
isScaled = true;
}
if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) {
image.scaleY = data.scaleY;
isScaled = true;
}
}
if (isRotated) {
this.renderCanvas();
} else if (isScaled) {
this.renderImage();
}
ratio = image.width / image.naturalWidth;
if (isNumber(data.x)) {
cropBoxData.left = data.x * ratio + canvas.left;
}
if (isNumber(data.y)) {
cropBoxData.top = data.y * ratio + canvas.top;
}
if (isNumber(data.width)) {
cropBoxData.width = data.width * ratio;
}
if (isNumber(data.height)) {
cropBoxData.height = data.height * ratio;
}
this.setCropBoxData(cropBoxData);
}
},
/**
* Get the container size data
*
* @return {Object} data
*/
getContainerData: function () {
return this.isBuilt ? this.container : {};
},
/**
* Get the image position and size data
*
* @return {Object} data
*/
getImageData: function () {
return this.isLoaded ? this.image : {};
},
/**
* Get the canvas position and size data
*
* @return {Object} data
*/
getCanvasData: function () {
var canvas = this.canvas;
var data = {};
if (this.isBuilt) {
$.each([
'left',
'top',
'width',
'height',
'naturalWidth',
'naturalHeight'
], function (i, n) {
data[n] = canvas[n];
});
}
return data;
},
/**
* Set the canvas position and size with new data
*
* @param {Object} data
*/
setCanvasData: function (data) {
var canvas = this.canvas;
var aspectRatio = canvas.aspectRatio;
if ($.isFunction(data)) {
data = data.call(this.$element);
}
if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) {
if (isNumber(data.left)) {
canvas.left = data.left;
}
if (isNumber(data.top)) {
canvas.top = data.top;
}
if (isNumber(data.width)) {
canvas.width = data.width;
canvas.height = data.width / aspectRatio;
} else if (isNumber(data.height)) {
canvas.height = data.height;
canvas.width = data.height * aspectRatio;
}
this.renderCanvas(true);
}
},
/**
* Get the crop box position and size data
*
* @return {Object} data
*/
getCropBoxData: function () {
var cropBox = this.cropBox;
var data;
if (this.isBuilt && this.isCropped) {
data = {
left: cropBox.left,
top: cropBox.top,
width: cropBox.width,
height: cropBox.height
};
}
return data || {};
},
/**
* Set the crop box position and size with new data
*
* @param {Object} data
*/
setCropBoxData: function (data) {
var cropBox = this.cropBox;
var aspectRatio = this.options.aspectRatio;
var isWidthChanged;
var isHeightChanged;
if ($.isFunction(data)) {
data = data.call(this.$element);
}
if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) {
if (isNumber(data.left)) {
cropBox.left = data.left;
}
if (isNumber(data.top)) {
cropBox.top = data.top;
}
if (isNumber(data.width)) {
isWidthChanged = true;
cropBox.width = data.width;
}
if (isNumber(data.height)) {
isHeightChanged = true;
cropBox.height = data.height;
}
if (aspectRatio) {
if (isWidthChanged) {
cropBox.height = cropBox.width / aspectRatio;
} else if (isHeightChanged) {
cropBox.width = cropBox.height * aspectRatio;
}
}
this.renderCropBox();
}
},
/**
* Get a canvas drawn the cropped image
*
* @param {Object} options (optional)
* @return {HTMLCanvasElement} canvas
*/
getCroppedCanvas: function (options) {
var originalWidth;
var originalHeight;
var canvasWidth;
var canvasHeight;
var scaledWidth;
var scaledHeight;
var scaledRatio;
var aspectRatio;
var canvas;
var context;
var data;
if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) {
return;
}
if (!$.isPlainObject(options)) {
options = {};
}
data = this.getData();
originalWidth = data.width;
originalHeight = data.height;
aspectRatio = originalWidth / originalHeight;
if ($.isPlainObject(options)) {
scaledWidth = options.width;
scaledHeight = options.height;
if (scaledWidth) {
scaledHeight = scaledWidth / aspectRatio;
scaledRatio = scaledWidth / originalWidth;
} else if (scaledHeight) {
scaledWidth = scaledHeight * aspectRatio;
scaledRatio = scaledHeight / originalHeight;
}
}
// The canvas element will use `Math.floor` on a float number, so floor first
canvasWidth = floor(scaledWidth || originalWidth);
canvasHeight = floor(scaledHeight || originalHeight);
canvas = $('<canvas>')[0];
canvas.width = canvasWidth;
canvas.height = canvasHeight;
context = canvas.getContext('2d');
if (options.fillColor) {
context.fillStyle = options.fillColor;
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage
context.drawImage.apply(context, (function () {
var source = getSourceCanvas(this.$clone[0], this.image);
var sourceWidth = source.width;
var sourceHeight = source.height;
var args = [source];
// Source canvas
var srcX = data.x;
var srcY = data.y;
var srcWidth;
var srcHeight;
// Destination canvas
var dstX;
var dstY;
var dstWidth;
var dstHeight;
if (srcX <= -originalWidth || srcX > sourceWidth) {
srcX = srcWidth = dstX = dstWidth = 0;
} else if (srcX <= 0) {
dstX = -srcX;
srcX = 0;
srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX);
} else if (srcX <= sourceWidth) {
dstX = 0;
srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX);
}
if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) {
srcY = srcHeight = dstY = dstHeight = 0;
} else if (srcY <= 0) {
dstY = -srcY;
srcY = 0;
srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY);
} else if (srcY <= sourceHeight) {
dstY = 0;
srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY);
}
// All the numerical parameters should be integer for `drawImage` (#476)
args.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight));
// Scale destination sizes
if (scaledRatio) {
dstX *= scaledRatio;
dstY *= scaledRatio;
dstWidth *= scaledRatio;
dstHeight *= scaledRatio;
}
// Avoid "IndexSizeError" in IE and Firefox
if (dstWidth > 0 && dstHeight > 0) {
args.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight));
}
return args;
}).call(this));
return canvas;
},
/**
* Change the aspect ratio of the crop box
*
* @param {Number} aspectRatio
*/
setAspectRatio: function (aspectRatio) {
var options = this.options;
if (!this.isDisabled && !isUndefined(aspectRatio)) {
// 0 -> NaN
options.aspectRatio = max(0, aspectRatio) || NaN;
if (this.isBuilt) {
this.initCropBox();
if (this.isCropped) {
this.renderCropBox();
}
}
}
},
/**
* Change the drag mode
*
* @param {String} mode (optional)
*/
setDragMode: function (mode) {
var options = this.options;
var croppable;
var movable;
if (this.isLoaded && !this.isDisabled) {
croppable = mode === ACTION_CROP;
movable = options.movable && mode === ACTION_MOVE;
mode = (croppable || movable) ? mode : ACTION_NONE;
this.$dragBox.
data(DATA_ACTION, mode).
toggleClass(CLASS_CROP, croppable).
toggleClass(CLASS_MOVE, movable);
if (!options.cropBoxMovable) {
// Sync drag mode to crop box when it is not movable(#300)
this.$face.
data(DATA_ACTION, mode).
toggleClass(CLASS_CROP, croppable).
toggleClass(CLASS_MOVE, movable);
}
}
}
};
Cropper.DEFAULTS = {
// Define the view mode of the cropper
viewMode: 0, // 0, 1, 2, 3
// Define the dragging mode of the cropper
dragMode: 'crop', // 'crop', 'move' or 'none'
// Define the aspect ratio of the crop box
aspectRatio: NaN,
// An object with the previous cropping result data
data: null,
// A jQuery selector for adding extra containers to preview
preview: '',
// Re-render the cropper when resize the window
responsive: true,
// Restore the cropped area after resize the window
restore: true,
// Check if the current image is a cross-origin image
checkCrossOrigin: true,
// Check the current image's Exif Orientation information
checkOrientation: true,
// Show the black modal
modal: true,
// Show the dashed lines for guiding
guides: true,
// Show the center indicator for guiding
center: true,
// Show the white modal to highlight the crop box
highlight: true,
// Show the grid background
background: true,
// Enable to crop the image automatically when initialize
autoCrop: true,
// Define the percentage of automatic cropping area when initializes
autoCropArea: 0.8,
// Enable to move the image
movable: true,
// Enable to rotate the image
rotatable: true,
// Enable to scale the image
scalable: true,
// Enable to zoom the image
zoomable: true,
// Enable to zoom the image by dragging touch
zoomOnTouch: true,
// Enable to zoom the image by wheeling mouse
zoomOnWheel: true,
// Define zoom ratio when zoom the image by wheeling mouse
wheelZoomRatio: 0.1,
// Enable to move the crop box
cropBoxMovable: true,
// Enable to resize the crop box
cropBoxResizable: true,
// Toggle drag mode between "crop" and "move" when click twice on the cropper
toggleDragModeOnDblclick: true,
// Size limitation
minCanvasWidth: 0,
minCanvasHeight: 0,
minCropBoxWidth: 0,
minCropBoxHeight: 0,
minContainerWidth: 200,
minContainerHeight: 100,
// Shortcuts of events
build: null,
built: null,
cropstart: null,
cropmove: null,
cropend: null,
crop: null,
zoom: null
};
Cropper.setDefaults = function (options) {
$.extend(Cropper.DEFAULTS, options);
};
Cropper.TEMPLATE = (
'<div class="cropper-container">' +
'<div class="cropper-wrap-box">' +
'<div class="cropper-canvas"></div>' +
'</div>' +
'<div class="cropper-drag-box"></div>' +
'<div class="cropper-crop-box">' +
'<span class="cropper-view-box"></span>' +
'<span class="cropper-dashed dashed-h"></span>' +
'<span class="cropper-dashed dashed-v"></span>' +
'<span class="cropper-center"></span>' +
'<span class="cropper-face"></span>' +
'<span class="cropper-line line-e" data-action="e"></span>' +
'<span class="cropper-line line-n" data-action="n"></span>' +
'<span class="cropper-line line-w" data-action="w"></span>' +
'<span class="cropper-line line-s" data-action="s"></span>' +
'<span class="cropper-point point-e" data-action="e"></span>' +
'<span class="cropper-point point-n" data-action="n"></span>' +
'<span class="cropper-point point-w" data-action="w"></span>' +
'<span class="cropper-point point-s" data-action="s"></span>' +
'<span class="cropper-point point-ne" data-action="ne"></span>' +
'<span class="cropper-point point-nw" data-action="nw"></span>' +
'<span class="cropper-point point-sw" data-action="sw"></span>' +
'<span class="cropper-point point-se" data-action="se"></span>' +
'</div>' +
'</div>'
);
// Save the other cropper
Cropper.other = $.fn.cropper;
// Register as jQuery plugin
$.fn.cropper = function (option) {
var args = toArray(arguments, 1);
var result;
this.each(function () {
var $this = $(this);
var data = $this.data(NAMESPACE);
var options;
var fn;
if (!data) {
if (/destroy/.test(option)) {
return;
}
options = $.extend({}, $this.data(), $.isPlainObject(option) && option);
$this.data(NAMESPACE, (data = new Cropper(this, options)));
}
if (typeof option === 'string' && $.isFunction(fn = data[option])) {
result = fn.apply(data, args);
}
});
return isUndefined(result) ? this : result;
};
$.fn.cropper.Constructor = Cropper;
$.fn.cropper.setDefaults = Cropper.setDefaults;
// No conflict
$.fn.cropper.noConflict = function () {
$.fn.cropper = Cropper.other;
return this;
};
});
/*!
* Cropper v2.2.5
* https://github.com/fengyuanchen/cropper
*
* Copyright (c) 2014-2016 Fengyuan Chen and contributors
* Released under the MIT license
*
* Date: 2016-01-18T05:42:29.639Z
*/
.cropper-container {
font-size: 0;
line-height: 0;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
direction: ltr !important;
-ms-touch-action: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
.cropper-container img {
display: block;
width: 100%;
min-width: 0 !important;
max-width: none !important;
height: 100%;
min-height: 0 !important;
max-height: none !important;
image-orientation: 0deg !important;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.cropper-wrap-box {
overflow: hidden;
}
.cropper-drag-box {
opacity: 0;
background-color: #fff;
filter: alpha(opacity=0);
}
.cropper-modal {
opacity: .5;
background-color: #000;
filter: alpha(opacity=50);
}
.cropper-view-box {
display: block;
overflow: hidden;
width: 100%;
height: 100%;
outline: 1px solid #39f;
outline-color: rgba(51, 153, 255, .75);
}
.cropper-dashed {
position: absolute;
display: block;
opacity: .5;
border: 0 dashed #eee;
filter: alpha(opacity=50);
}
.cropper-dashed.dashed-h {
top: 33.33333%;
left: 0;
width: 100%;
height: 33.33333%;
border-top-width: 1px;
border-bottom-width: 1px;
}
.cropper-dashed.dashed-v {
top: 0;
left: 33.33333%;
width: 33.33333%;
height: 100%;
border-right-width: 1px;
border-left-width: 1px;
}
.cropper-center {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 0;
height: 0;
opacity: .75;
filter: alpha(opacity=75);
}
.cropper-center:before,
.cropper-center:after {
position: absolute;
display: block;
content: ' ';
background-color: #eee;
}
.cropper-center:before {
top: 0;
left: -3px;
width: 7px;
height: 1px;
}
.cropper-center:after {
top: -3px;
left: 0;
width: 1px;
height: 7px;
}
.cropper-face,
.cropper-line,
.cropper-point {
position: absolute;
display: block;
width: 100%;
height: 100%;
opacity: .1;
filter: alpha(opacity=10);
}
.cropper-face {
top: 0;
left: 0;
background-color: #fff;
}
.cropper-line {
background-color: #39f;
}
.cropper-line.line-e {
top: 0;
right: -3px;
width: 5px;
cursor: e-resize;
}
.cropper-line.line-n {
top: -3px;
left: 0;
height: 5px;
cursor: n-resize;
}
.cropper-line.line-w {
top: 0;
left: -3px;
width: 5px;
cursor: w-resize;
}
.cropper-line.line-s {
bottom: -3px;
left: 0;
height: 5px;
cursor: s-resize;
}
.cropper-point {
width: 5px;
height: 5px;
opacity: .75;
background-color: #39f;
filter: alpha(opacity=75);
}
.cropper-point.point-e {
top: 50%;
right: -3px;
margin-top: -3px;
cursor: e-resize;
}
.cropper-point.point-n {
top: -3px;
left: 50%;
margin-left: -3px;
cursor: n-resize;
}
.cropper-point.point-w {
top: 50%;
left: -3px;
margin-top: -3px;
cursor: w-resize;
}
.cropper-point.point-s {
bottom: -3px;
left: 50%;
margin-left: -3px;
cursor: s-resize;
}
.cropper-point.point-ne {
top: -3px;
right: -3px;
cursor: ne-resize;
}
.cropper-point.point-nw {
top: -3px;
left: -3px;
cursor: nw-resize;
}
.cropper-point.point-sw {
bottom: -3px;
left: -3px;
cursor: sw-resize;
}
.cropper-point.point-se {
right: -3px;
bottom: -3px;
width: 20px;
height: 20px;
cursor: se-resize;
opacity: 1;
filter: alpha(opacity=100);
}
.cropper-point.point-se:before {
position: absolute;
right: -50%;
bottom: -50%;
display: block;
width: 200%;
height: 200%;
content: ' ';
opacity: 0;
background-color: #39f;
filter: alpha(opacity=0);
}
@media (min-width: 768px) {
.cropper-point.point-se {
width: 15px;
height: 15px;
}
}
@media (min-width: 992px) {
.cropper-point.point-se {
width: 10px;
height: 10px;
}
}
@media (min-width: 1200px) {
.cropper-point.point-se {
width: 5px;
height: 5px;
opacity: .75;
filter: alpha(opacity=75);
}
}
.cropper-invisible {
opacity: 0;
filter: alpha(opacity=0);
}
.cropper-bg {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
}
.cropper-hide {
position: absolute;
display: block;
width: 0;
height: 0;
}
.cropper-hidden {
display: none !important;
}
.cropper-move {
cursor: move;
}
.cropper-crop {
cursor: crosshair;
}
.cropper-disabled .cropper-drag-box,
.cropper-disabled .cropper-face,
.cropper-disabled .cropper-line,
.cropper-disabled .cropper-point {
cursor: not-allowed;
}
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