From 33ba32e6cc12eb8a8bddd957bb99bd2df7628a70 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 29 Feb 2016 14:47:21 +0000 Subject: [PATCH 01/59] Applications tab on profile settings Closes #13855 --- app/assets/stylesheets/pages/profile.scss | 14 ++ .../oauth/applications_controller.rb | 6 +- app/controllers/profiles_controller.rb | 1 + .../applications/_delete_form.html.haml | 8 +- .../doorkeeper/applications/_form.html.haml | 31 ++-- app/views/profiles/applications.html.haml | 145 ++++++++++-------- 6 files changed, 119 insertions(+), 86 deletions(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4826b994e37..a3fc127e405 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -175,3 +175,17 @@ color: $profile-settings-link-color; } } + +.profile-settings-message { + line-height: 32px; + color: #9E8E60; + background-color: #FBF2D9; + border: 1px solid #F0E2BB; + border-radius: 3px; +} + +.oauth-applications { + form { + display: inline-block; + } +} diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index dc22101cd5e..d983ae0b8c6 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -11,6 +11,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController head :forbidden and return end + def new + redirect_to applications_profile_url + end + def create @application = Doorkeeper::Application.new(application_params) @@ -20,7 +24,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - render :new + redirect_to applications_profile_url, flash: {application: @application} end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a1148961..75eb9bdb96f 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -13,6 +13,7 @@ class ProfilesController < Profiles::ApplicationController @authorized_tokens = current_user.oauth_authorized_tokens @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) @authorized_apps = @authorized_tokens.map(&:application).uniq - [nil] + @application = flash[:application] || Doorkeeper::Application.new end def update diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml index 6a5c917049d..001a711b1dd 100644 --- a/app/views/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/doorkeeper/applications/_delete_form.html.haml @@ -1,4 +1,10 @@ - submit_btn_css ||= 'btn btn-link btn-remove btn-sm' = form_tag oauth_application_path(application) do %input{:name => "_method", :type => "hidden", :value => "delete"}/ - = submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css \ No newline at end of file + - if defined? small + = button_tag type: "submit", class: "btn btn-transparent", data: { confirm: "Are you sure?" } do + %span.sr-only + Destroy + = icon('trash') + - else + = submit_tag 'Destroy', data: { confirm: "Are you sure?" }, class: submit_btn_css diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 98a61ab211b..0df8362d4ad 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f| += form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f| - if application.errors.any? .alert.alert-danger %ul @@ -6,25 +6,20 @@ %li= msg .form-group - = f.label :name, class: 'control-label' - - .col-sm-10 - = f.text_field :name, class: 'form-control', required: true + = f.label :name, class: 'label-light' + = f.text_field :name, class: 'form-control', required: true .form-group - = f.label :redirect_uri, class: 'control-label' - - .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control', required: true + = f.label :redirect_uri, class: 'label-light' + = f.text_area :redirect_uri, class: 'form-control', required: true + %span.help-block + Use one line per URI + - if Doorkeeper.configuration.native_redirect_uri %span.help-block - Use one line per URI - - if Doorkeeper.configuration.native_redirect_uri - %span.help-block - Use - %code= Doorkeeper.configuration.native_redirect_uri - for local tests + Use + %code= Doorkeeper.configuration.native_redirect_uri + for local tests - .form-actions - = f.submit 'Submit', class: "btn btn-create" - = link_to "Cancel", applications_profile_path, class: "btn btn-cancel" + .prepend-top-default + = f.submit 'Add application', class: "btn btn-create" diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml index 86f35823406..911ba9f87f0 100644 --- a/app/views/profiles/applications.html.haml +++ b/app/views/profiles/applications.html.haml @@ -1,70 +1,83 @@ - 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. +.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 + Add new application + = render 'doorkeeper/applications/form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th{width: 105} + %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 'doorkeeper/applications/delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20 + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) -- 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 + - 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 '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 + .profile-settings-message.text-center + You don't have any authorized applications From c2377a11957c6e5f8514ff0d68b2af343d3427d0 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 1 Mar 2016 14:37:49 +0000 Subject: [PATCH 02/59] Fixed failing application settings tests --- app/views/doorkeeper/applications/_form.html.haml | 2 +- features/profile/profile.feature | 3 +-- features/steps/profile/profile.rb | 10 +++------- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 0df8362d4ad..906b0676150 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -22,4 +22,4 @@ for local tests .prepend-top-default - = f.submit 'Add application', class: "btn btn-create" + = f.submit 'Save application', class: "btn btn-create" diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 168d9d30b50..447dd92a458 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -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 diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 0c60328583a..b7d0a17b98e 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -180,18 +180,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 +207,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 From be390cff65de0bf6f0fc1f059c4243fcb85d43d0 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 1 Mar 2016 15:08:17 +0000 Subject: [PATCH 03/59] Fixed Ruby style error --- app/controllers/oauth/applications_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index d983ae0b8c6..e20446b2cce 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -24,7 +24,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - redirect_to applications_profile_url, flash: {application: @application} + redirect_to applications_profile_url, flash: { application: @application } end end From b03df1758bfa592c0bb6804f1f6bf540b792f3e9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 2 Mar 2016 09:15:06 +0000 Subject: [PATCH 04/59] Moved scss values into variables Fixed heading weight --- app/assets/stylesheets/framework/variables.scss | 5 +++++ app/assets/stylesheets/pages/profile.scss | 8 ++++---- app/views/profiles/applications.html.haml | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 6fc62f7f201..5ab40f14300 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,6 +101,11 @@ $border-red-dark: #CA264F; $help-well-bg: #FAFAFA; $help-well-border: #E5E5E5; +$settings-message-bg: #FBF2D9; +$settings-message-color: #9E8E60; +$settings-message-border: #F0E2BB; +$settings-message-radius: 3px; + /* header */ $light-grey-header: #faf9f9; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index a3fc127e405..efb592b1c04 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -178,10 +178,10 @@ .profile-settings-message { line-height: 32px; - color: #9E8E60; - background-color: #FBF2D9; - border: 1px solid #F0E2BB; - border-radius: 3px; + color: $settings-message-color; + background-color: $settings-message-bg; + border: 1px solid $settings-message-border; + border-radius: $settings-message-radius; } .oauth-applications { diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml index 911ba9f87f0..e072c18beb4 100644 --- a/app/views/profiles/applications.html.haml +++ b/app/views/profiles/applications.html.haml @@ -13,7 +13,7 @@ Manage applications that you've authorized to use your account. .col-lg-9 - if user_oauth_applications? - %h5 + %h5.prepend-top-0 Add new application = render 'doorkeeper/applications/form', application: @application %hr @@ -23,7 +23,7 @@ Your applications (#{@applications.size}) - if @applications.any? .table-responsive - %table.table.table-striped + %table.table %thead %tr %th Name @@ -47,7 +47,7 @@ - else .profile-settings-message.text-center You don't have any applications - .oauth-authorized-applications.prepend-top-20 + .oauth-authorized-applications.prepend-top-20.append-bottom-default - if user_oauth_applications? %h5 Authorized applications (#{@authorized_tokens.size}) From 9f673aa498fed240db5c408b10d9107cec938aa3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 3 Mar 2016 08:38:56 +0000 Subject: [PATCH 05/59] Removed page specific variables --- app/assets/stylesheets/framework/variables.scss | 7 +++---- app/assets/stylesheets/pages/profile.scss | 12 ++++++++---- app/views/profiles/applications.html.haml | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 5ab40f14300..406effbb6a4 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,10 +101,9 @@ $border-red-dark: #CA264F; $help-well-bg: #FAFAFA; $help-well-border: #E5E5E5; -$settings-message-bg: #FBF2D9; -$settings-message-color: #9E8E60; -$settings-message-border: #F0E2BB; -$settings-message-radius: 3px; +$warning-message-bg: #FBF2D9; +$warning-message-color: #9E8E60; +$warning-message-border: #F0E2BB; /* header */ $light-grey-header: #faf9f9; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index efb592b1c04..1612b090fed 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -178,14 +178,18 @@ .profile-settings-message { line-height: 32px; - color: $settings-message-color; - background-color: $settings-message-bg; - border: 1px solid $settings-message-border; - border-radius: $settings-message-radius; + color: $warning-message-color; + background-color: $warning-message-bg; + border: 1px solid $warning-message-border; + border-radius: $border-radius-base; } .oauth-applications { form { display: inline-block; } + + .last-heading { + width: 105px; + } } diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml index e072c18beb4..7c0f700d68d 100644 --- a/app/views/profiles/applications.html.haml +++ b/app/views/profiles/applications.html.haml @@ -29,7 +29,7 @@ %th Name %th Callback URL %th Clients - %th{width: 105} + %th.last-heading %tbody - @applications.each do |application| %tr{:id => "application_#{application.id}"} From e632bd26e4b70b100e5c9891b4b2cc7e267080c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 1 Mar 2016 12:32:20 +0100 Subject: [PATCH 06/59] Fixes "iid of max iid" in Issuable sidebar for merged MR Fixes #13928 --- CHANGELOG | 1 + app/helpers/issuables_helper.rb | 6 +++++- features/project/merge_requests.feature | 9 ++++++++- features/steps/project/merge_requests.rb | 20 ++++++++++++++++++++ features/steps/shared/issuable.rb | 4 ++++ spec/factories/merge_requests.rb | 5 +++++ 6 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a98bdd26f74..658ed5aef72 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,6 +16,7 @@ v 8.6.0 (unreleased) - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users - 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) - Show labels in dashboard and group milestone views - Add main language of a project in the list of projects (Tiago Botelho) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 91a3aa371ef..2dfeddf7368 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -31,7 +31,11 @@ module IssuablesHelper end def issuable_state_scope(issuable) - issuable.open? ? :opened : :closed + if issuable.respond_to?(:merged?) && issuable.merged? + :merged + else + issuable.open? ? :opened : :closed + end end end diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index f8d9fe1854d..74685d24a7d 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -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" diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index c19b15bc9ed..2eb98e91dbf 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -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", diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index ae10c6069a9..7c094bab8aa 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -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' diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index ca1c636fce4..a9df5fa1d3a 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -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] From c8d06b7001e03ed3f3a63e146e6f3b0f615f4d94 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Thu, 10 Mar 2016 11:09:31 -0500 Subject: [PATCH 07/59] Set link title for todos. Closes #14042 --- app/helpers/todos_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 4b745a5b969..f41a4e15cb3 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo) + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), {title: todo.target.nil? ? h(target) : h(todo.target.title)} end def todo_target_path(todo) From c554d0cf6451c8807373a74f5d6cac36212c6c91 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Thu, 10 Mar 2016 13:29:59 -0500 Subject: [PATCH 08/59] Rubocop --- app/helpers/todos_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index f41a4e15cb3..03523e93696 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), {title: todo.target.nil? ? h(target) : h(todo.target.title)} + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: todo.target.nil? ? h(target) : h(todo.target.title) } end def todo_target_path(todo) From ad4d3a075fc338280baaf6240861c9de7aa312ad Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 11 Mar 2016 13:39:11 +0100 Subject: [PATCH 09/59] Describe special YAML features: the use of anchors and hidden jobs --- CHANGELOG | 2 + doc/ci/yaml/README.md | 71 ++++++++++++++++++++ lib/ci/gitlab_ci_yaml_processor.rb | 2 + spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 29 ++++++++ 4 files changed, 104 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 1847c5193ab..66675e39427 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,6 +16,8 @@ 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) - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 051eaa04152..ec57ac5789e 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -509,6 +509,77 @@ 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. +## Special features + +It's possible special YAML features like anchors and map merging. +Thus allowing to greatly reduce the complexity of `.gitlab-ci.yml`. + +#### Anchors + +You can read more about YAML features [here](https://learnxinyminutes.com/docs/yaml/). + +```yaml +.job_template: &job_definition + image: ruby:2.1 + services: + - postgres + - redis + +test1: + << *job_definition + script: + - test project + +test2: + << *job_definition + script: + - test project +``` + +The above example uses anchors and map merging. +It will create a two jobs: `test1` and `test2` that will have the parameters of `.job_template` and custom `script` defined. + +```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 above example uses anchors to define two set of services. +It will create a two jobs: `test:postgres` and `test:mysql` that will have the script defined in `.job_template` +and one, the service defined in `.postgres_services` and second the services defined in `.mysql_services`. + +### Hidden jobs + +The jobs that start with `.` will be not processed by GitLab. + +Example of such hidden jobs: +```yaml +.job_name: + script: + - rake spec +``` + +The `.job_name` will be ignored. You can use this feature to ignore jobs, or use them as templates with special YAML features. + ## Validate the .gitlab-ci.yml Each instance of GitLab CI has an embedded debug tool called Lint. diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 1a3f662811a..8ece73eec0e 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -60,6 +60,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 +82,7 @@ module Ci services: job[:services] || @services, artifacts: job[:artifacts], cache: job[:cache] || @cache, + dependencies: job[:dependencies], }.compact } end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index f3394910c5b..665a65fe352 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -427,6 +427,35 @@ module Ci 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 "Error handling" do it "fails to parse YAML" do expect{GitlabCiYamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError) From d300ecf8d9e886ee7cff9b883bfcdbdb1e49769b Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 11 Mar 2016 13:43:57 +0100 Subject: [PATCH 10/59] Allow to pass name of created artifacts archive in `.gitlab-ci.yml` --- CHANGELOG | 1 + doc/ci/yaml/README.md | 61 ++++++++++++++++++++ lib/ci/gitlab_ci_yaml_processor.rb | 4 ++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 10 +++- 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 66675e39427..c171d662b8d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,7 @@ v 8.6.0 (unreleased) - 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` - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ec57ac5789e..9a1f86cec45 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -453,6 +453,67 @@ 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 created artifacts archive. + +Currently the `artifacts` is used. +It may be useful when you will want to download the archive from GitLab. +You could possible have the unique name of every archive. + +The `artifacts:name` variable can use any of the [predefined variables](../variables/README.md). + +--- + +**Example configurations** + +To create a archive with a name of current build: + +```yaml +job: + artifacts: + name: "$CI_BUILD_NAME" +``` + +To create a archive with a name of current branch or tag: + +```yaml +job: + artifacts: + name: "$CI_BUILD_REF_NAME" + untracked: true +``` + +To create a archive with a name of current branch or tag: + +```yaml +job: + artifacts: + name: "${CI_BUILD_NAME}_${CI_BUILD_REF_NAME}" + untracked: true +``` + +To create a archive with a name of stage 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 +``` + ### cache _**Note:** Introduced in GitLab Runner v0.7.0._ diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 8ece73eec0e..ce3d0138268 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -218,6 +218,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 diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 665a65fe352..44d2b9eb1f7 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -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 } @@ -619,6 +620,13 @@ module Ci 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 From 9a271d80128451eecc9a301d5e9924c09740be07 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 11 Mar 2016 14:15:13 +0100 Subject: [PATCH 11/59] Allow to define on which builds the current one depends on --- CHANGELOG | 1 + doc/ci/yaml/README.md | 55 ++++++++++++++++++++ lib/ci/gitlab_ci_yaml_processor.rb | 21 +++++++- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 45 ++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index c171d662b8d..fa634cc20db 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -22,6 +22,7 @@ v 8.6.0 (unreleased) - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users + - Allow to define on which builds the current one depends on - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio) - Don't show Issues/MRs from archived projects in Groups view - Increase the notes polling timeout over time (Roberto Dip) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 9a1f86cec45..fb62ed25d64 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -241,6 +241,7 @@ job_name: | 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 a builds that this build depends on | | artifacts | no | Define list build artifacts | | cache | no | Define list of files that should be cached between subsequent runs | @@ -514,6 +515,60 @@ job: untracked: true ``` +### dependencies + +_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.1._ + +This feature should be used with `artifacts` and allows to define artifacts passing between different builds. + +`artifacts` from previous stages are passed by default. + +To use a feature define `dependencies` in context of the build and pass +a list of all previous builds from which the artifacts should be downloaded. +You can only define a builds from stages that are executed before this one. +Error will be shown if you define builds from current stage or next stages. + +How to use artifacts passing between stages: + +``` +build:osx: + stage: build + script: ... + artifacts: + paths: + - binaries/ + +build:linux: + stage: build + script: ... + artifacts: + paths: + - binaries/ + +test:osx: + stage: test + script: ... + dependencies: + - build:osx + +test:linux: + stage: test + script: ... + dependencies: + - build:linux + +deploy: + stage: deploy + script: ... +``` + +The above will create a build artifacts for two jobs: `build:osx` and `build:linux`. +When executing the `test:osx` the artifacts for `build:osx` will be downloaded and extracted in 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. +However, only the `build:osx` and `build:linux` exports artifacts so only these will be downloaded. + ### cache _**Note:** Introduced in GitLab Runner v0.7.0._ diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index ce3d0138268..04b58cf1cd3 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -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 @@ -145,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 @@ -231,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 diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 44d2b9eb1f7..fe5096989b2 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -428,6 +428,44 @@ 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({ @@ -682,6 +720,13 @@ module Ci 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 From 52c934a8eb78a331cb7f246fdc547604edf589aa Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Thu, 10 Mar 2016 20:30:50 -0500 Subject: [PATCH 12/59] Title attributes for activity feed --- app/helpers/events_helper.rb | 8 ++++---- app/helpers/projects_helper.rb | 2 +- app/helpers/todos_helper.rb | 2 +- app/views/events/_commit.html.haml | 2 +- app/views/events/_event_last_push.html.haml | 2 +- app/views/events/event/_common.html.haml | 2 +- app/views/events/event/_push.html.haml | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index e5fcaab9551..babfc0d8c89 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -3,7 +3,7 @@ module EventsHelper author = event.author if author - link_to author.name, user_path(author.username) + link_to author.name, user_path(author.username), title: 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: event.target_title), class: "commit_short_id" ) do "#{event.note_target_type} #{event.note_short_commit_id}" @@ -167,11 +167,11 @@ 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: event.project.name) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else - link_to event_note_target_path(event) do + link_to event_note_target_path(event), title: event.note_target.to_reference do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c8061fcdc59..c10b49cb6bb 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -8,7 +8,7 @@ module ProjectsHelper end def link_to_project(project) - link_to [project.namespace.becomes(Namespace), project] do + link_to [project.namespace.becomes(Namespace), project], title: project.name do title = content_tag(:span, project.name, class: 'project-name') if project.namespace diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 03523e93696..07ddc691d85 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: todo.target.nil? ? h(target) : h(todo.target.title) } + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: h(todo.target.title) } end def todo_target_path(todo) diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 4ba8b84fd92..dce4081288c 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '' + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index abea86b026a..fc60241ae7b 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -3,7 +3,7 @@ .event-last-push .event-last-push-text %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.project.name do %strong= event.ref_name %span at %strong= link_to_project event.project diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index e9e16a7646f..e7bc5b75946 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -4,7 +4,7 @@ = event_action_name(event) - if event.target - %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target] + %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], title: event.target.to_reference = event_preposition(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 8bed5cdb9cc..b7470db81e0 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -5,7 +5,7 @@ %strong= event.ref_name - else %strong - = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.target_title at = link_to_project event.project From af29ed3773e1071fe6071d1dc358402d61d223c2 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Fri, 11 Mar 2016 15:53:27 -0500 Subject: [PATCH 13/59] Escape! --- app/helpers/events_helper.rb | 8 ++++---- app/helpers/projects_helper.rb | 2 +- app/views/events/_event_last_push.html.haml | 2 +- app/views/events/event/_common.html.haml | 2 +- app/views/events/event/_push.html.haml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index babfc0d8c89..6cda2d8a651 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -3,7 +3,7 @@ module EventsHelper author = event.author if author - link_to author.name, user_path(author.username), title: author.name + 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), title: event.target_title), + 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,11 +167,11 @@ module EventsHelper elsif event.note_project_snippet? link_to(namespace_project_snippet_path(event.project.namespace, event.project, - event.note_target), title: event.project.name) do + event.note_target), title: h(event.project.name)) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else - link_to event_note_target_path(event), title: event.note_target.to_reference do + link_to event_note_target_path(event), title: h(event.note_target.to_reference) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c10b49cb6bb..b5acb80b720 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -8,7 +8,7 @@ module ProjectsHelper end def link_to_project(project) - link_to [project.namespace.becomes(Namespace), project], title: project.name do + link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') if project.namespace diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index fc60241ae7b..5753158c24d 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -3,7 +3,7 @@ .event-last-push .event-last-push-text %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.project.name do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do %strong= event.ref_name %span at %strong= link_to_project event.project diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index e7bc5b75946..e9e16a7646f 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -4,7 +4,7 @@ = event_action_name(event) - if event.target - %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], title: event.target.to_reference + %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target] = event_preposition(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index b7470db81e0..235bd46107e 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -5,7 +5,7 @@ %strong= event.ref_name - else %strong - = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.target_title + = 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 From 19d1455ac136ab06c2153714761eadfb9ada0f0d Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sat, 12 Mar 2016 20:22:38 +0200 Subject: [PATCH 14/59] Change notes to new styleguide using blockquotes --- doc/ci/yaml/README.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index fb62ed25d64..a4d621c0e57 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -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, @@ -394,12 +396,12 @@ 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. +> - Limited 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. @@ -456,12 +458,13 @@ be available for download in the GitLab UI. #### artifacts:name -_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.0._ +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.0. The `name` directive allows you to define the name of created artifacts archive. -Currently the `artifacts` is used. -It may be useful when you will want to download the archive from GitLab. +Currently the `artifacts` is used. +It may be useful when you will want to download the archive from GitLab. You could possible have the unique name of every archive. The `artifacts:name` variable can use any of the [predefined variables](../variables/README.md). @@ -517,13 +520,14 @@ job: ### dependencies -_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.1._ +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. This feature should be used with `artifacts` and allows to define artifacts passing between different builds. `artifacts` from previous stages are passed by default. -To use a feature define `dependencies` in context of the build and pass +To use a feature define `dependencies` in context of the build and pass a list of all previous builds from which the artifacts should be downloaded. You can only define a builds from stages that are executed before this one. Error will be shown if you define builds from current stage or next stages. @@ -537,7 +541,7 @@ build:osx: artifacts: paths: - binaries/ - + build:linux: stage: build script: ... @@ -571,7 +575,8 @@ However, only the `build:osx` and `build:linux` exports artifacts so only these ### 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: @@ -665,7 +670,7 @@ It will create a two jobs: `test1` and `test2` that will have the parameters of - postgres - ruby -.mysql_services: +.mysql_services: services: &mysql_definition - mysql - ruby From 49e51753001d0b54adf785792141844c0e89dd70 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:33:10 +0200 Subject: [PATCH 15/59] Refactor artifacts:name --- doc/ci/yaml/README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index a4d621c0e57..83928b81594 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -461,19 +461,16 @@ be available for download in the GitLab UI. >**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.0. -The `name` directive allows you to define the name of created artifacts archive. - -Currently the `artifacts` is used. -It may be useful when you will want to download the archive from GitLab. -You could possible have the unique name of every archive. - -The `artifacts:name` variable can use any of the [predefined variables](../variables/README.md). +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 a archive with a name of current build: +To create an archive with a name of the current build: ```yaml job: @@ -481,7 +478,8 @@ job: name: "$CI_BUILD_NAME" ``` -To create a archive with a name of current branch or tag: +To create an archive with a name of the current branch or tag including only +the files that are untracked by Git: ```yaml job: @@ -490,7 +488,8 @@ job: untracked: true ``` -To create a archive with a name of current branch or tag: +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: @@ -499,7 +498,7 @@ job: untracked: true ``` -To create a archive with a name of stage and branch name: +To create an archive with a name of the current [stage](#stages) and branch name: ```yaml job: @@ -508,6 +507,8 @@ job: untracked: true ``` +--- + If you use **Windows Batch** to run your shell scripts you need to replace `$` with `%`: From 372abbe7f95d89290d46ef356fc13cc0a886c317 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:34:46 +0200 Subject: [PATCH 16/59] Refactor YAML anchors and explain the examples --- doc/ci/yaml/README.md | 106 +++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 83928b81594..244cec71c8c 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -631,35 +631,78 @@ 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. -## Special features +## Special YAML features -It's possible special YAML features like anchors and map merging. -Thus allowing to greatly reduce the complexity of `.gitlab-ci.yml`. +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`. -#### Anchors +Read more about the various [YAML features](https://learnxinyminutes.com/docs/yaml/). -You can read more about YAML features [here](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 +.job_template: &job_definition # Hidden job that defines an anchor named 'job_definition' image: ruby:2.1 services: - postgres - redis test1: - << *job_definition + <<: *job_definition # Merge the contents of the 'job_definition' alias script: - - test project + - test1 project test2: - << *job_definition + <<: *job_definition # Merge the contents of the 'job_definition' alias script: - - test project + - test2 project ``` -The above example uses anchors and map merging. -It will create a two jobs: `test1` and `test2` that will have the parameters of `.job_template` and custom `script` defined. +`&` 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 @@ -685,22 +728,39 @@ test:mysql: services: *mysql_definition ``` -The above example uses anchors to define two set of services. -It will create a two jobs: `test:postgres` and `test:mysql` that will have the script defined in `.job_template` -and one, the service defined in `.postgres_services` and second the services defined in `.mysql_services`. +The expanded version looks like this: -### Hidden jobs - -The jobs that start with `.` will be not processed by GitLab. - -Example of such hidden jobs: ```yaml -.job_name: +.job_template: script: - - rake spec + - 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 ``` -The `.job_name` will be ignored. You can use this feature to ignore jobs, or use them as templates with special YAML features. +You can see that the hidden jobs are conveniently used as templates. ## Validate the .gitlab-ci.yml From de7c3316b08e7763ac860503578e7fa4c78d2b9d Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:35:40 +0200 Subject: [PATCH 17/59] Add hidden jobs --- doc/ci/yaml/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 244cec71c8c..5f3a53dcf8e 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -631,6 +631,24 @@ 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 (`*`) From d8eeeb692ed61454eb06e3276f368f4dc0f11d81 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:57:36 +0200 Subject: [PATCH 18/59] Refactor dependencies directive [ci skip] --- doc/ci/yaml/README.md | 47 ++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 5f3a53dcf8e..beaa86250a9 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -236,14 +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 a builds that this build depends on | +| 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 | @@ -404,7 +404,10 @@ The above script will: > - 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`: @@ -524,56 +527,58 @@ job: >**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.1. -This feature should be used with `artifacts` and allows to define artifacts passing between different builds. +This feature should be used in conjunction with [`artifacts`](#artifacts) and +allows you to define the artifacts to pass between different builds. -`artifacts` from previous stages are passed by default. +Note that `artifacts` from previous [stages](#stages) are passed by default. -To use a feature define `dependencies` in context of the build and pass +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 a builds from stages that are executed before this one. -Error will be shown if you define builds from current stage or next stages. +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. -How to use artifacts passing between stages: +--- + +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: ``` build:osx: stage: build - script: ... + script: make build:osx artifacts: paths: - binaries/ build:linux: stage: build - script: ... + script: make build:linux artifacts: paths: - binaries/ test:osx: stage: test - script: ... + script: make test:osx dependencies: - build:osx test:linux: stage: test - script: ... + script: make test:linux dependencies: - build:linux deploy: stage: deploy - script: ... + script: make deploy ``` -The above will create a build artifacts for two jobs: `build:osx` and `build:linux`. -When executing the `test:osx` the artifacts for `build:osx` will be downloaded and extracted in 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. -However, only the `build:osx` and `build:linux` exports artifacts so only these will be downloaded. - ### cache >**Note:** From a181722ae4dc0b1bb564d34b30e30f823cc72bc0 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 13 Mar 2016 13:39:28 -0700 Subject: [PATCH 19/59] Improve award emoji test reliability and eliminate sleeps Closes #14129 --- features/steps/project/issues/award_emoji.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index 135e1d016ae..ce2554bc80d 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -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 From 8f21e2ae408a4ebd0e115846b9a639e7ce09a126 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Fri, 11 Mar 2016 23:38:25 -0500 Subject: [PATCH 20/59] Let `oauth/applications#index` handle the `profiles#applications` route Previously we were doing all of kinds of code gymnastics and flash abuse in order to work with a Doorkeeper controller but have it _appear_ at the `/profile/applications` path. Fortunately we can just tell Rails to use a different controller to handle that route, and we get the best of both worlds. --- .../oauth/applications_controller.rb | 28 +++--- app/controllers/profiles_controller.rb | 8 -- .../doorkeeper/applications/index.html.haml | 98 +++++++++++++++---- app/views/layouts/nav/_profile.html.haml | 2 +- app/views/profiles/applications.html.haml | 83 ---------------- config/routes.rb | 2 +- 6 files changed, 97 insertions(+), 124 deletions(-) delete mode 100644 app/views/profiles/applications.html.haml diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index e20446b2cce..d1e4ac10f6c 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -8,11 +8,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController layout 'profile' def index - head :forbidden and return - end - - def new - redirect_to applications_profile_url + set_index_vars end def create @@ -24,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 - redirect_to applications_profile_url, flash: { application: @application } + 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 @@ -44,6 +33,17 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController redirect_to applications_profile_url end + def set_index_vars + @applications = current_user.oauth_applications + @authorized_tokens = current_user.oauth_authorized_tokens + @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) + @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + + # Don't overwrite a value possibly set by `create` + @application ||= Doorkeeper::Application.new + end + + # Override Doorkeeper to scope to the current user def set_application @application = current_user.oauth_applications.find(params[:id]) end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 75eb9bdb96f..50b8f38eecb 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,14 +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] - @application = flash[:application] || Doorkeeper::Application.new - end - def update user_params.except!(:email) if @user.ldap_user? diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index ba4c5b86efb..ea0b66c932b 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,19 +1,83 @@ - page_title "Applications" -%h3.page-title Your applications -%p= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' +- header_title page_title, applications_profile_path -.table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td= application.redirect_uri - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link' - %td= render 'delete_form', application: application +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + - if user_oauth_applications? + Manage applications that can use GitLab as an OAuth provider, + and applications that you've authorized to use your account. + - else + Manage applications that you've authorized to use your account. + .col-lg-9 + - if user_oauth_applications? + %h5.prepend-top-0 + Add new application + = render 'form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th.last-heading + %tbody + - @applications.each do |application| + %tr{id: "application_#{application.id}"} + %td= link_to application.name, oauth_application_path(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td + = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + %span.sr-only + Edit + = icon('pencil') + = render 'delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20.append-bottom-default + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) + + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Authorized At + %th Scope + %th + %tbody + - @authorized_apps.each do |app| + - token = app.authorized_tokens.order('created_at desc').first + %tr{id: "application_#{app.id}"} + %td= app.name + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', application: app + - @authorized_anonymous_tokens.each do |token| + %tr + %td + Anonymous + %div.help-block + %em Authorization was granted by entering your username and password in the application. + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', token: token + - else + .profile-settings-message.text-center + You don't have any authorized applications diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index f3ded04419b..3b9d31a6fc5 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -17,7 +17,7 @@ = icon('gear fw') %span Account - = nav_link(path: ['profiles#applications', 'applications#edit', 'applications#show', 'applications#new', 'applications#create']) do + = nav_link(controller: 'oauth/applications') do = link_to applications_profile_path, title: 'Applications' do = icon('cloud fw') %span diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml deleted file mode 100644 index 7c0f700d68d..00000000000 --- a/app/views/profiles/applications.html.haml +++ /dev/null @@ -1,83 +0,0 @@ -- page_title "Applications" -- header_title page_title, applications_profile_path - -.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 'doorkeeper/applications/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 'doorkeeper/applications/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 '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 - .profile-settings-message.text-center - You don't have any authorized applications diff --git a/config/routes.rb b/config/routes.rb index a918b5bd3f0..92aaedf7b6e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 From dc3c9f5a7f50858269901439605e518d2d1c2e04 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Sun, 13 Mar 2016 21:57:39 -0400 Subject: [PATCH 21/59] Remove last to_reference --- app/helpers/events_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 6cda2d8a651..37a888d9c60 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -171,7 +171,7 @@ module EventsHelper "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else - link_to event_note_target_path(event), title: h(event.note_target.to_reference) do + link_to event_note_target_path(event) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end From 7ebb932070f03e8fb6116b9f54148cae2d782776 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Mon, 14 Mar 2016 10:14:32 +0200 Subject: [PATCH 22/59] Add yaml definition to codeblock [ci skip] --- doc/ci/yaml/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index beaa86250a9..4d27c07913d 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -547,7 +547,7 @@ 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 From 9d6e0c5071e640685f16d5f6045e89851db4b868 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 4 Mar 2016 09:45:05 +0000 Subject: [PATCH 23/59] Left sidebar overlaps the content on mobile devices --- app/assets/javascripts/sidebar.js.coffee | 17 +++++++++++++---- app/assets/stylesheets/framework/header.scss | 10 +++++----- app/assets/stylesheets/framework/sidebar.scss | 8 ++++++-- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index cff309c5972..dfa69dd6a47 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,8 +1,8 @@ -$(document).on("click", '.toggle-nav-collapse', (e) -> - e.preventDefault() - collapsed = 'page-sidebar-collapsed' - expanded = 'page-sidebar-expanded' +mobileWidth = 768 +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 +14,13 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> niceScrollBars.updateScrollBar(); ), 300 +$(document).on("click", '.toggle-nav-collapse', (e) -> + e.preventDefault() + + toggleSidebar() ) + +$(document).ready -> + if $(window).width() < mobileWidth + if $('.page-with-sidebar').hasClass(expanded) + toggleSidebar() diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index e624982c5c9..3d3d3295615 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,17 +141,17 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-md-max) { +@media (max-width: $screen-sm) { .header-collapsed { margin-left: $sidebar_collapsed_width; } - .header-expanded { - margin-left: $sidebar_width; - } + .header-expanded { + margin-left: $sidebar_collapsed_width; + } } -@media(min-width: $screen-md-max) { +@media(min-width: $screen-sm) { .header-collapsed { @include collapsed-header; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 6b382e4b1b2..0f83bd8b556 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -39,7 +39,7 @@ } .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-sm-min) { + padding-left: $sidebar_width; + } &.right-sidebar-collapsed { /* Extra small devices (phones, less than 768px) */ From b155d54941ab5bb4eb07c1774ba3a2dfbb9f2707 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 10 Mar 2016 16:55:44 +0000 Subject: [PATCH 24/59] Removed deprecated bootstrap variables --- app/assets/stylesheets/framework/header.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 3d3d3295615..9aa97f6ac47 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,7 +141,7 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-sm) { +@media (max-width: $screen-sm-min) { .header-collapsed { margin-left: $sidebar_collapsed_width; } @@ -151,7 +151,7 @@ header { } } -@media(min-width: $screen-sm) { +@media(min-width: $screen-sm-min) { .header-collapsed { @include collapsed-header; } From ce9bbce78a258b43181766bcbebad7add90968ff Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 10 Mar 2016 17:04:45 +0000 Subject: [PATCH 25/59] changed jquery document ready --- app/assets/javascripts/sidebar.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index dfa69dd6a47..1ffb6619ab5 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -20,7 +20,7 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> toggleSidebar() ) -$(document).ready -> +$ -> if $(window).width() < mobileWidth if $('.page-with-sidebar').hasClass(expanded) toggleSidebar() From f0f6723fe02bfb1a79c1cbefed156a420dddc352 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 11 Mar 2016 15:11:11 +0000 Subject: [PATCH 26/59] Created bootstrap breakpoint class This class checks the current bootstrap breakpoint --- app/assets/javascripts/application.js.coffee | 27 +++---------------- app/assets/javascripts/breakpoints.coffee | 24 +++++++++++++++++ app/assets/javascripts/sidebar.js.coffee | 7 +++-- app/assets/stylesheets/framework/header.scss | 18 +++++-------- app/assets/stylesheets/framework/sidebar.scss | 2 +- 5 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 app/assets/javascripts/breakpoints.coffee diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 1212e89975b..9e114a80c27 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -256,35 +256,15 @@ $ -> $('.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('
'+ - '
'+ - '
'+ - '
') - checkBootstrapBreakpoints() - fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint checkBootstrapBreakpoints() + bootstrapBreakpoint = breakpoints.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) checkInitialSidebarSize = -> + bootstrapBreakpoint = breakpoints.getBreakpointSize() if bootstrapBreakpoint is "xs" or "sm" $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) @@ -293,6 +273,7 @@ $ -> .on "resize", (e) -> fitSidebarForSize() - setBootstrapBreakpoints() checkInitialSidebarSize() + breakpoints = new Breakpoints() + bootstrapBreakpoint = breakpoints.getBreakpointSize() new Aside() diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee new file mode 100644 index 00000000000..fd2ee8efa2c --- /dev/null +++ b/app/assets/javascripts/breakpoints.coffee @@ -0,0 +1,24 @@ +class @Breakpoints + BREAKPOINTS = ["xs", "sm", "md", "lg"] + + constructor: -> + @setup() + + setup: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + + if $(allDeviceSelector.join(",")).length + return + + # Create all the elements + $.each BREAKPOINTS, (i, breakpoint) -> + $("body").append "
" + + getBreakpointSize: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + + $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") + + return $visibleDevice.attr("class").split("visible-")[1] diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index 1ffb6619ab5..5c68690979d 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,4 +1,4 @@ -mobileWidth = 768 +mobileWidth = 991 collapsed = 'page-sidebar-collapsed' expanded = 'page-sidebar-expanded' @@ -21,6 +21,9 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> ) $ -> - if $(window).width() < mobileWidth + breakpoints = new Breakpoints() + size = breakpoints.getBreakpointSize() + + if size is "xs" or size is "sm" if $('.page-with-sidebar').hasClass(expanded) toggleSidebar() diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 9aa97f6ac47..a73643b35ad 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,22 +141,18 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-sm-min) { - .header-collapsed { - margin-left: $sidebar_collapsed_width; - } +.header-collapsed { + margin-left: $sidebar_collapsed_width; - .header-expanded { - margin-left: $sidebar_collapsed_width; + @media (min-width: $screen-sm-max) { + @include collapsed-header; } } -@media(min-width: $screen-sm-min) { - .header-collapsed { - @include collapsed-header; - } +.header-expanded { + margin-left: $sidebar_collapsed_width; - .header-expanded { + @media (min-width: $screen-sm-max) { margin-left: $sidebar_width; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 0f83bd8b556..24608e6cff2 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -205,7 +205,7 @@ @mixin expanded-sidebar { padding-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-min) { + @media (min-width: $screen-sm-max) { padding-left: $sidebar_width; } From 23b0eeca280de8c1f36f863396cb0aee912c56b9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 11 Mar 2016 15:13:47 +0000 Subject: [PATCH 27/59] removed un-used function call --- app/assets/javascripts/application.js.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 9e114a80c27..beee6f2ec6d 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -108,6 +108,9 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + breakpoints = new Breakpoints() + bootstrapBreakpoint = breakpoints.getBreakpointSize() + $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") # Click a .js-select-on-focus field, select the contents @@ -258,7 +261,6 @@ $ -> fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint - checkBootstrapBreakpoints() bootstrapBreakpoint = breakpoints.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) @@ -274,6 +276,4 @@ $ -> fitSidebarForSize() checkInitialSidebarSize() - breakpoints = new Breakpoints() - bootstrapBreakpoint = breakpoints.getBreakpointSize() new Aside() From 1a5992b5eb15a58223925877e90f7642aac6ce2d Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 11:51:07 +0000 Subject: [PATCH 28/59] Changed breakpoint size Changed breakpoint class into singleton --- app/assets/javascripts/application.js.coffee | 7 ++- app/assets/javascripts/breakpoints.coffee | 43 +++++++++++-------- app/assets/javascripts/sidebar.js.coffee | 4 +- app/assets/stylesheets/framework/header.scss | 4 +- app/assets/stylesheets/framework/sidebar.scss | 4 +- 5 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index beee6f2ec6d..e9c6196e926 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -108,8 +108,7 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> - breakpoints = new Breakpoints() - bootstrapBreakpoint = breakpoints.getBreakpointSize() + bootstrapBreakpoint = bp.getBreakpointSize() $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") @@ -261,12 +260,12 @@ $ -> fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint - bootstrapBreakpoint = breakpoints.getBreakpointSize() + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) checkInitialSidebarSize = -> - bootstrapBreakpoint = breakpoints.getBreakpointSize() + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint is "xs" or "sm" $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee index fd2ee8efa2c..73a42d99982 100644 --- a/app/assets/javascripts/breakpoints.coffee +++ b/app/assets/javascripts/breakpoints.coffee @@ -1,24 +1,33 @@ class @Breakpoints - BREAKPOINTS = ["xs", "sm", "md", "lg"] + instance = null; - constructor: -> - @setup() + class BreakpointInstance + BREAKPOINTS = ["xs", "sm", "md", "lg"] - setup: -> - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> - ".device-#{breakpoint}" + constructor: -> + @setup() - if $(allDeviceSelector.join(",")).length - return + setup: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" - # Create all the elements - $.each BREAKPOINTS, (i, breakpoint) -> - $("body").append "
" + return if $(allDeviceSelector.join(",")).length - getBreakpointSize: -> - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> - ".device-#{breakpoint}" + # Create all the elements + $.each BREAKPOINTS, (i, breakpoint) -> + $("body").append "
" - $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") - - return $visibleDevice.attr("class").split("visible-")[1] + getBreakpointSize: -> + @setup() + + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + + $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") + + return $visibleDevice.attr("class").split("visible-")[1] + + @get: -> + return instance ?= new BreakpointInstance + +@bp = Breakpoints.get() diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index 5c68690979d..eea3f5ee910 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,4 +1,3 @@ -mobileWidth = 991 collapsed = 'page-sidebar-collapsed' expanded = 'page-sidebar-expanded' @@ -21,8 +20,7 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> ) $ -> - breakpoints = new Breakpoints() - size = breakpoints.getBreakpointSize() + size = bp.getBreakpointSize() if size is "xs" or size is "sm" if $('.page-with-sidebar').hasClass(expanded) diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index a73643b35ad..4c4033e3ae7 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -144,7 +144,7 @@ header { .header-collapsed { margin-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-max) { + @media (min-width: $screen-md-min) { @include collapsed-header; } } @@ -152,7 +152,7 @@ header { .header-expanded { margin-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-max) { + @media (min-width: $screen-md-min) { margin-left: $sidebar_width; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 24608e6cff2..26df9acd2ae 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -34,7 +34,7 @@ @media (min-width: $screen-sm-min) { padding-right: $gutter_width; } - + } } @@ -205,7 +205,7 @@ @mixin expanded-sidebar { padding-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-max) { + @media (min-width: $screen-md-min) { padding-left: $sidebar_width; } From 0672258915a0cf444802ffc50ad1cd914f4f11d4 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Wed, 9 Mar 2016 16:24:02 +0100 Subject: [PATCH 29/59] Cleanup CiCommit and CiBuild - Remove all view related methods from Ci::Build and CommitStatus - Remove unused Ci::Commit and Ci::Build methods - Use polymorphism to render different types of CommitStatus --- app/models/ci/build.rb | 32 +------- app/models/ci/commit.rb | 32 +------- app/models/commit_status.rb | 18 +---- app/services/ci/image_for_build_service.rb | 2 +- app/services/create_commit_builds_service.rb | 1 - app/views/admin/builds/_build.html.haml | 21 +++-- app/views/ci/commits/_commit.html.haml | 32 -------- app/views/projects/builds/index.html.haml | 3 +- app/views/projects/builds/show.html.haml | 19 ++--- app/views/projects/ci/builds/_build.html.haml | 78 ++++++++++++++++++ app/views/projects/commit/_builds.html.haml | 7 +- .../commit_statuses/_commit_status.html.haml | 79 ------------------- .../_generic_commit_status.html.haml | 60 ++++++++++++++ doc/api/builds.md | 7 -- features/steps/shared/builds.rb | 2 +- lib/api/entities.rb | 7 -- spec/models/build_spec.rb | 32 +------- spec/models/ci/commit_spec.rb | 46 +---------- 18 files changed, 174 insertions(+), 304 deletions(-) delete mode 100644 app/views/ci/commits/_commit.html.haml create mode 100644 app/views/projects/ci/builds/_build.html.haml delete mode 100644 app/views/projects/commit_statuses/_commit_status.html.haml create mode 100644 app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1227458e525..6c1ca8db24f 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -128,7 +128,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 +309,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 +317,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 +332,6 @@ module Ci artifacts_file.exists? end - def artifacts_download_url - if artifacts? - download_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - - def artifacts_browse_url - if artifacts_metadata? - browse_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - def artifacts_metadata? artifacts? && artifacts_metadata.exists? end diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index ecbd2078b1d..12c60158d46 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -25,8 +25,6 @@ module Ci has_many :builds, class_name: 'Ci::Build' has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest' - scope :ordered, -> { order('CASE WHEN ci_commits.committed_at IS NULL THEN 0 ELSE 1 END', :committed_at, :id) } - validates_presence_of :sha validate :valid_commit_sha @@ -42,16 +40,6 @@ module Ci project.id end - def last_build - builds.order(:id).last - end - - def retry - latest_builds.each do |build| - Ci::Build.retry(build) - end - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -121,12 +109,8 @@ module Ci @latest_statuses ||= statuses.latest.to_a end - def latest_builds - @latest_builds ||= builds.latest.to_a - end - - def latest_builds_for_ref(ref) - latest_builds.select { |build| build.ref == ref } + def latest_statuses_for_ref(ref) + latest_statuses.select { |status| status.ref == ref } end def retried @@ -170,7 +154,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 +167,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 +198,6 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - def update_committed! - update!(committed_at: DateTime.now) - end - private def save_yaml_error(error) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 7ef50836322..3b1aa0f5c80 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -125,23 +125,7 @@ class CommitStatus < ActiveRecord::Base end end - def cancel_url - nil - end - - def retry_url - nil - end - - def show_warning? + def stuck? false end - - def artifacts_download_url - nil - end - - def artifacts_browse_url - nil - end end diff --git a/app/services/ci/image_for_build_service.rb b/app/services/ci/image_for_build_service.rb index 005a5c4661c..50c95ced8a7 100644 --- a/app/services/ci/image_for_build_service.rb +++ b/app/services/ci/image_for_build_service.rb @@ -3,7 +3,7 @@ module Ci def execute(project, opts) sha = opts[:sha] || ref_sha(project, opts[:ref]) - commit = project.ci_commits.ordered.find_by(sha: sha) + commit = project.ci_commits.find_by(sha: sha) image_name = image_for_commit(commit) image_path = Rails.root.join('public/ci', image_name) diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 31b407efeb1..69d5c42a877 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -33,7 +33,6 @@ class CreateCommitBuildsService unless commit.skip_ci? # Create builds for commit tag = Gitlab::Git.tag_ref?(origin_ref) - commit.update_committed! commit.create_builds(ref, tag, user) end diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index 34d955568f2..588ad767426 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -4,13 +4,13 @@ = ci_status_with_icon(build.status) %td.build-link - - if can?(current_user, :read_build, project) && build.target_url - = link_to build.target_url do + - if can?(current_user, :read_build, build.project) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do %strong Build ##{build.id} - else %strong Build ##{build.id} - - if build.show_warning? + - if build.stuck? %i.fa.fa-warning.text-warning %td @@ -18,11 +18,11 @@ = link_to project.name_with_namespace, admin_namespace_project_path(project.namespace, project), class: "monospace" %td - = link_to build.short_sha, namespace_project_commit_path(project.namespace, project, build.sha), class: "monospace" + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" %td - if build.ref - = link_to build.ref, namespace_project_commits_path(project.namespace, project, build.ref) + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) - else .light none @@ -61,13 +61,12 @@ %td .pull-right - if can?(current_user, :read_build, project) && build.artifacts? - = link_to build.artifacts_download_url, title: 'Download artifacts' do + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do %i.fa.fa-download - if can?(current_user, :update_build, build.project) - if build.active? - - if build.cancel_url - = link_to build.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && build.retry_url - = link_to build.retry_url, method: :post, title: 'Retry' do + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do %i.fa.fa-repeat diff --git a/app/views/ci/commits/_commit.html.haml b/app/views/ci/commits/_commit.html.haml deleted file mode 100644 index 11163813f3e..00000000000 --- a/app/views/ci/commits/_commit.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -%tr.build - %td.status - = ci_status_with_icon(commit.status) - - if commit.running? - · - = commit.stage - - - %td.build-link - = link_to ci_status_path(commit) do - %strong #{commit.short_sha} - - %td.build-message - %span= truncate_first_line(commit.git_commit_message) - - %td.build-branch - - unless @ref - %span - - commit.refs.each do |ref| - = link_to truncate(ref, length: 25), ci_project_path(@project, ref: ref) - - %td.duration - - if commit.duration > 0 - #{time_interval_in_words commit.duration} - - %td.timestamp - - if commit.finished_at - %span #{time_ago_in_words commit.finished_at} ago - - - if commit.coverage - %td.coverage - #{commit.coverage}% diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml index 14f1d3226bb..811d304ea75 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -55,7 +55,6 @@ %th Coverage %th - - @builds.each do |build| - = render 'projects/commit_statuses/commit_status', commit_status: build, commit_sha: true, stage: true, coverage: @project.build_coverage_enabled?, allow_retry: true + = render @builds, commit_sha: true, stage: true, allow_retry: true, coverage: @project.build_coverage_enabled? = paginate @builds, theme: 'gitlab' diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index be7cc0f256c..dbbf382fa2a 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -13,9 +13,10 @@ = link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request) #up-build-trace - - if @commit.matrix_for_ref?(@build.ref) + - builds = @build.commit.builds.similar(@build).latest.ordered.to_a + - 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), diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml new file mode 100644 index 00000000000..195fd4a9d51 --- /dev/null +++ b/app/views/projects/ci/builds/_build.html.haml @@ -0,0 +1,78 @@ +%tr.build + %td.status + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do + = ci_icon_for_status(build.status) + = build.status + - else + = ci_status_with_icon(build.status) + + %td.build-link + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do + %strong ##{build.id} + - else + %strong ##{build.id} + + - if build.stuck? + %i.fa.fa-warning.text-warning + + - if defined?(commit_sha) && commit_sha + %td + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" + + %td + - if build.ref + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if build.try(:runner) + = runner_link(build.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = build.stage + + %td + = build.name + + .pull-right + - if build.tags.any? + - build.tags.each do |tag| + %span.label.label-primary + = tag + - if build.try(:trigger_request) + %span.label.label-info triggered + - if build.try(:allow_failure) + %span.label.label-danger allowed to fail + + %td.duration + - if build.duration + #{duration_in_words(build.finished_at, build.started_at)} + + %td.timestamp + - if build.finished_at + %span #{time_ago_with_tooltip(build.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if build.try(:coverage) + #{build.coverage}% + + %td + .pull-right + - if can?(current_user, :read_build, build) && build.artifacts? + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do + %i.fa.fa-download + - if can?(current_user, :update_build, build) + - if build.active? + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do + %i.fa.fa-repeat diff --git a/app/views/projects/commit/_builds.html.haml b/app/views/projects/commit/_builds.html.haml index befad27666c..003b7c18d0e 100644 --- a/app/views/projects/commit/_builds.html.haml +++ b/app/views/projects/commit/_builds.html.haml @@ -43,8 +43,8 @@ %th Coverage %th - @ci_commit.refs.each do |ref| - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.statuses.for_ref(ref).latest.ordered, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true } + - builds = @ci_commit.statuses.for_ref(ref).latest.ordered + = render builds, coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true - if @ci_commit.retried.any? .gray-content-block.second-block @@ -64,5 +64,4 @@ - if @ci_commit.project.build_coverage_enabled? %th Coverage %th - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.retried, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true } + = render @ci_commit.retried, coverage: @ci_commit.project.build_coverage_enabled?, stage: true diff --git a/app/views/projects/commit_statuses/_commit_status.html.haml b/app/views/projects/commit_statuses/_commit_status.html.haml deleted file mode 100644 index a3449d1ae05..00000000000 --- a/app/views/projects/commit_statuses/_commit_status.html.haml +++ /dev/null @@ -1,79 +0,0 @@ -%tr.commit_status - %td.status - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url, class: "ci-status ci-#{commit_status.status}" do - = ci_icon_for_status(commit_status.status) - = commit_status.status - - else - = ci_status_with_icon(commit_status.status) - - %td.commit_status-link - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url do - %strong ##{commit_status.id} - - else - %strong ##{commit_status.id} - - - if commit_status.show_warning? - %i.fa.fa-warning.text-warning{data: { toggle: "tooltip" }, title: "This build is stuck, open it to know more"} - - - if defined?(commit_sha) && commit_sha - %td - = link_to commit_status.short_sha, namespace_project_commit_path(commit_status.project.namespace, commit_status.project, commit_status.sha), class: "monospace" - - %td - - if commit_status.ref - = link_to commit_status.ref, namespace_project_commits_path(commit_status.project.namespace, commit_status.project, commit_status.ref) - - else - .light none - - - if defined?(runner) && runner - %td - - if commit_status.try(:runner) - = runner_link(commit_status.runner) - - else - .light none - - - if defined?(stage) && stage - %td - = commit_status.stage - - %td - = commit_status.name - - .pull-right - - if commit_status.tags.any? - - commit_status.tags.each do |tag| - %span.label.label-primary - = tag - - if commit_status.try(:trigger_request) - %span.label.label-info triggered - - if commit_status.try(:allow_failure) - %span.label.label-danger allowed to fail - - %td.duration - - if commit_status.duration - #{duration_in_words(commit_status.finished_at, commit_status.started_at)} - - %td.timestamp - - if commit_status.finished_at - %span #{time_ago_with_tooltip(commit_status.finished_at)} - - - if defined?(coverage) && coverage - %td.coverage - - if commit_status.try(:coverage) - #{commit_status.coverage}% - - %td - .pull-right - - if can?(current_user, :read_commit_status, commit_status) && commit_status.artifacts_download_url - = link_to commit_status.artifacts_download_url, title: 'Download artifacts' do - %i.fa.fa-download - - if can?(current_user, :update_commit_status, commit_status) - - if commit_status.active? - - if commit_status.cancel_url - = link_to commit_status.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && commit_status.retry_url - = link_to commit_status.retry_url, method: :post, title: 'Retry' do - %i.fa.fa-repeat diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml new file mode 100644 index 00000000000..ac29f323b4c --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -0,0 +1,60 @@ +%tr.generic_commit_status + %td.status + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do + = ci_icon_for_status(generic_commit_status.status) + = generic_commit_status.status + - else + = ci_status_with_icon(generic_commit_status.status) + + %td.generic_commit_status-link + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url do + %strong ##{generic_commit_status.id} + - else + %strong ##{generic_commit_status.id} + + - if defined?(commit_sha) && commit_sha + %td + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "monospace" + + %td + - if generic_commit_status.ref + = link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if generic_commit_status.try(:runner) + = runner_link(generic_commit_status.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = generic_commit_status.stage + + %td + = generic_commit_status.name + + .pull-right + - if generic_commit_status.tags.any? + - generic_commit_status.tags.each do |tag| + %span.label.label-primary + = tag + + %td.duration + - if generic_commit_status.duration + #{duration_in_words(generic_commit_status.finished_at, generic_commit_status.started_at)} + + %td.timestamp + - if generic_commit_status.finished_at + %span #{time_ago_with_tooltip(generic_commit_status.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if generic_commit_status.try(:coverage) + #{generic_commit_status.coverage}% + + %td diff --git a/doc/api/builds.md b/doc/api/builds.md index d3ce72e59fc..4c0a47d1ea0 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -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, diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index f33ed7834fe..c4c7672a432 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -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 diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 5b5b8bd044b..de58ef176cd 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -401,13 +401,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) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index e3d3d453653..f4f817297b9 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -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,34 +343,6 @@ 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 subject { build.artifacts? } diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 4dc309a4255..c51444f4875 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -23,7 +23,7 @@ describe Ci::Commit, models: true do let(:commit) { FactoryGirl.create :ci_commit, project: project } it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:statuses) } + it { is_expected.to have_many(:commit_statuses) } it { is_expected.to have_many(:trigger_requests) } it { is_expected.to have_many(:builds) } it { is_expected.to validate_presence_of :sha } @@ -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 From f32e28f6faab176845a780d5b4d26881c08bcfec Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 10 Mar 2016 11:06:33 +0100 Subject: [PATCH 30/59] Fix commit_spec: invalid validation --- spec/models/ci/commit_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index c51444f4875..412842337ba 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -23,7 +23,7 @@ describe Ci::Commit, models: true do let(:commit) { FactoryGirl.create :ci_commit, project: project } it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:commit_statuses) } + it { is_expected.to have_many(:statuses) } it { is_expected.to have_many(:trigger_requests) } it { is_expected.to have_many(:builds) } it { is_expected.to validate_presence_of :sha } From 16592e2b45d42e22f9d1d595a1f44821c7b30441 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 14 Mar 2016 13:33:26 +0100 Subject: [PATCH 31/59] Fix review comments - Remove unused Gitlab::Application.routes.url_helpers from Ci::Build - Remove too much logic from a view, use Ci::Commit.matrix_builds - Use ci_status_with_icon - Don't describe symbols --- app/models/ci/build.rb | 2 -- app/models/ci/commit.rb | 6 ++++ app/views/projects/builds/show.html.haml | 2 +- app/views/projects/ci/builds/_build.html.haml | 3 +- .../_generic_commit_status.html.haml | 3 +- spec/models/build_spec.rb | 36 +++++++++---------- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6c1ca8db24f..7d33838044b 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -37,8 +37,6 @@ module Ci class Build < CommitStatus - include Gitlab::Application.routes.url_helpers - LAZY_ATTRIBUTES = ['trace'] belongs_to :runner, class_name: 'Ci::Runner' diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index 12c60158d46..f4cf7034b14 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -113,6 +113,12 @@ module Ci latest_statuses.select { |status| status.ref == ref } end + 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 @retried ||= (statuses.order(id: :desc) - statuses.latest) end diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index dbbf382fa2a..b02aee3db21 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -13,7 +13,7 @@ = link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request) #up-build-trace - - builds = @build.commit.builds.similar(@build).latest.ordered.to_a + - builds = @build.commit.matrix_builds(@build) - if builds.size > 1 %ul.nav-links.no-top.no-bottom - builds.each do |build| diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 195fd4a9d51..7123efffd5b 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -2,8 +2,7 @@ %td.status - if can?(current_user, :read_build, build) = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do - = ci_icon_for_status(build.status) - = build.status + = ci_status_with_icon(build.status) - else = ci_status_with_icon(build.status) diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index ac29f323b4c..4143ea13063 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -2,8 +2,7 @@ %td.status - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do - = ci_icon_for_status(generic_commit_status.status) - = generic_commit_status.status + = ci_status_with_icon(generic_commit_status.status) - else = ci_status_with_icon(generic_commit_status.status) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index f4f817297b9..b7457808040 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -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,7 +312,7 @@ describe Ci::Build, models: true do end end - describe :stuck? do + describe '#stuck?' do subject { build.stuck? } %w(pending).each do |state| @@ -343,7 +343,7 @@ describe Ci::Build, models: true do end end - describe :artifacts? do + describe '#artifacts?' do subject { build.artifacts? } context 'artifacts archive does not exist' do @@ -358,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 } @@ -370,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 } @@ -384,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' } @@ -416,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) From a965b03369594736d586f5a04a14de4451b9edd1 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 13:02:40 +0000 Subject: [PATCH 32/59] Removed call to setup from breakpoint size method --- app/assets/javascripts/breakpoints.coffee | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee index 73a42d99982..1ffaaddb055 100644 --- a/app/assets/javascripts/breakpoints.coffee +++ b/app/assets/javascripts/breakpoints.coffee @@ -14,12 +14,11 @@ class @Breakpoints return if $(allDeviceSelector.join(",")).length # Create all the elements - $.each BREAKPOINTS, (i, breakpoint) -> - $("body").append "
" + els = $.map BREAKPOINTS, (breakpoint) -> + "
" + $("body").append els.join('') getBreakpointSize: -> - @setup() - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> ".device-#{breakpoint}" @@ -30,4 +29,5 @@ class @Breakpoints @get: -> return instance ?= new BreakpointInstance -@bp = Breakpoints.get() +$ => + @bp = Breakpoints.get() From 543b85a0f555fafa2c5d257872bec25a4c21bd01 Mon Sep 17 00:00:00 2001 From: Jasper Denkers Date: Mon, 14 Mar 2016 13:21:31 +0000 Subject: [PATCH 33/59] Fix typo in Ruby CI test and deploy example --- doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md index c1bb47e4291..f5645d586ae 100644 --- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md @@ -1,5 +1,5 @@ ## 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). From a02fe251df7ea7316f51850fe603e7e5ac4431e2 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 14 Mar 2016 15:18:52 +0100 Subject: [PATCH 34/59] Allow project housekeeping only once an hour --- app/controllers/projects_controller.rb | 4 +- app/services/projects/housekeeping_service.rb | 14 +++++++ .../projects/housekeeping_service_spec.rb | 40 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 spec/services/projects/housekeeping_service_spec.rb diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c70add86a20..2a3dc5c79f7 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -170,10 +170,10 @@ class ProjectsController < ApplicationController end def housekeeping - ::Projects::HousekeepingService.new(@project).execute + message = ::Projects::HousekeepingService.new(@project).execute respond_to do |format| - flash[:notice] = "Housekeeping successfully started." + flash[:notice] = message format.html { redirect_to project_path(@project) } end end diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 0db85ac2142..11be5b1badf 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -9,12 +9,26 @@ module Projects class HousekeepingService < BaseService include Gitlab::ShellAdapter + LEASE_TIMEOUT = 3600 + def initialize(project) @project = project end def execute + if !try_obtain_lease + return "Housekeeping was already triggered in the past #{LEASE_TIMEOUT / 60} minutes" + end + GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) + return "Housekeeping successfully started" + end + + private + + def try_obtain_lease + lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT) + lease.try_obtain end end end diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb new file mode 100644 index 00000000000..7cddeb5c354 --- /dev/null +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -0,0 +1,40 @@ +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) + + expect(subject.execute).to include('successfully started') + 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 include('already triggered') + expect(project.pushes_since_gc).to eq(3) + 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 +end \ No newline at end of file From 021d53c96d308df7c721984435442993357a3414 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 14 Mar 2016 16:49:24 +0100 Subject: [PATCH 35/59] Run 'git gc' every 10 pushes --- app/services/git_push_service.rb | 8 ++++++ app/services/projects/housekeeping_service.rb | 16 +++++++++-- ...0314143402_projects_add_pushes_since_gc.rb | 5 ++++ db/schema.rb | 3 ++- spec/services/git_push_service_spec.rb | 27 +++++++++++++++++++ .../projects/housekeeping_service_spec.rb | 10 ++++++- 6 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20160314143402_projects_add_pushes_since_gc.rb diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bd31a617747..e50cbdfb602 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -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,12 @@ class GitPushService < BaseService ProjectCacheWorker.perform_async(@project.id) end + def perform_housekeeping + housekeeping = Projects::HousekeepingService.new(@project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + end + def process_default_branch @push_commits = project.repository.commits(params[:newrev]) diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 11be5b1badf..83bdedf7a8d 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -19,9 +19,21 @@ module Projects if !try_obtain_lease return "Housekeeping was already triggered in the past #{LEASE_TIMEOUT / 60} minutes" end - + GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) - return "Housekeeping successfully started" + @project.pushes_since_gc = 0 + @project.save! + + "Housekeeping successfully started" + end + + def needed? + @project.pushes_since_gc >= 10 + end + + def increment! + @project.pushes_since_gc += 1 + @project.save! end private diff --git a/db/migrate/20160314143402_projects_add_pushes_since_gc.rb b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb new file mode 100644 index 00000000000..5d30a38bc99 --- /dev/null +++ b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb @@ -0,0 +1,5 @@ +class ProjectsAddPushesSinceGc < ActiveRecord::Migration + def change + add_column :projects, :pushes_since_gc, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 3ac6203632d..cf3f8245b38 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160309140734) do +ActiveRecord::Schema.define(version: 20160314143402) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -711,6 +711,7 @@ ActiveRecord::Schema.define(version: 20160309140734) 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 diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index a7e2e1b1792..ebf3ec1f5fd 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -401,6 +401,33 @@ describe GitPushService, services: true do end end + describe "housekeeping" do + let(:housekeeping) { instance_double('Projects::HousekeepingService', increment!: nil, needed?: false) } + + 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 + + it 'performs housekeeping when needed' do + expect(housekeeping).to receive(:needed?).and_return(true) + expect(housekeeping).to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + 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 diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index 7cddeb5c354..32552d882aa 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -37,4 +37,12 @@ describe Projects::HousekeepingService do expect(subject.needed?).to eq(true) end end -end \ No newline at end of file + + 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 From 3cc040e11083b80e3d1c872240e2420bf74ae170 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 13 Mar 2016 22:52:19 -0700 Subject: [PATCH 36/59] Bump Capybara gem to 2.6.2 --- CHANGELOG | 1 + Gemfile | 2 +- Gemfile.lock | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b120810ebd8..8abab4f1418 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) - 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 diff --git a/Gemfile b/Gemfile index 1550afb1b56..26b61c2fa5b 100644 --- a/Gemfile +++ b/Gemfile @@ -273,7 +273,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' diff --git a/Gemfile.lock b/Gemfile.lock index d4e28db00d6..f719dd60e3f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -901,7 +902,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) From 6c791f7da7d87a496a0e0d1c8179badcb4490174 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Mon, 14 Mar 2016 18:31:14 +0100 Subject: [PATCH 37/59] Redesign project tabs on group page Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/framework/blocks.scss | 4 +++ app/views/groups/_projects.html.haml | 13 +------- app/views/groups/_shared_projects.html.haml | 19 +---------- app/views/groups/show.html.haml | 33 +++++++++++++------- 4 files changed, 27 insertions(+), 42 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 6edabe20136..d20b77ffae9 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -120,6 +120,10 @@ .cover-desc { padding: 0 $gl-padding 3px; color: $gl-text-color; + + &.username:last-child { + padding-bottom: $gl-padding; + } } .cover-controls { diff --git a/app/views/groups/_projects.html.haml b/app/views/groups/_projects.html.haml index 7cd8e9bea46..cca7dc27b1c 100644 --- a/app/views/groups/_projects.html.haml +++ b/app/views/groups/_projects.html.haml @@ -1,12 +1 @@ -.top-area - .nav-controls - = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - - if @projects.present? - = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false - = render 'shared/projects/dropdown' - - if can? current_user, :create_projects, @group - = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do - = icon('plus') - New Project - -= render 'shared/projects/list', projects: @projects, stars: false, skip_namespace: true += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: true diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index d707ad4272d..b1694c919d0 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -1,18 +1 @@ -- 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 diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index de314a4190c..23a34ac36dd 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -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 From 0beae70efaafc361cf15c13231bdc5ed6de8569f Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 14 Mar 2016 18:46:38 +0100 Subject: [PATCH 38/59] Use strings instead of symbols --- spec/services/projects/housekeeping_service_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index 32552d882aa..4c3577149f9 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -4,7 +4,7 @@ describe Projects::HousekeepingService do subject { Projects::HousekeepingService.new(project) } let(:project) { create :project } - describe :execute do + describe 'execute' do before do project.pushes_since_gc = 3 project.save! @@ -27,7 +27,7 @@ describe Projects::HousekeepingService do end end - describe :needed? do + describe 'needed?' do it 'when the count is low enough' do expect(subject.needed?).to eq(false) end @@ -38,7 +38,7 @@ describe Projects::HousekeepingService do end end - describe :increment! do + describe 'increment!' do it 'increments the pushes_since_gc counter' do expect(project.pushes_since_gc).to eq(0) subject.increment! From cef1f4f2d9bad1f246a3d34d454f7fc419988aa9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 20:04:05 +0000 Subject: [PATCH 39/59] Fixes issue with unassigned not working in dropdown Also allowed issues to be bulk unassigned --- app/assets/javascripts/gl_dropdown.js.coffee | 2 +- app/views/shared/issuable/_filter.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index b94de4c7b5e..4f038477755 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -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 = "" diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 4df96a06dbe..42a3c2c3f02 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -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 } }) From 41de7b345b0abdaba2f0d7614ebdb1cc7310a5fb Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 14 Mar 2016 16:07:51 -0400 Subject: [PATCH 40/59] Be more intelligent about sanitizing links with unsafe protocols This prevents false matches on relative links like `[database](database.md)`. Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/14220 --- lib/banzai/filter/sanitization_filter.rb | 9 +++++++-- .../banzai/filter/sanitization_filter_spec.rb | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index abd79b329ae..e8011519608 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -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 diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index 4a7b00c7660..27ce312b11c 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -149,10 +149,20 @@ describe Banzai::Filter::SanitizationFilter, lib: true do output: '' }, + 'protocol-based JS injection: invalid URL char' => { + input: '', + output: '' + }, + 'protocol-based JS injection: spaces and entities' => { input: 'foo', output: 'foo' }, + + 'protocol whitespace' => { + input: '', + output: '' + } } protocols.each do |name, data| @@ -177,6 +187,16 @@ describe Banzai::Filter::SanitizationFilter, lib: true do expect(output.to_html).to eq 'XSS' end + it 'disallows invalid URIs' do + expect(Addressable::URI).to receive(:parse).with('foo://example.com'). + and_raise(Addressable::URI::InvalidURIError) + + input = 'Foo' + output = filter(input) + + expect(output.to_html).to eq 'Foo' + end + it 'allows non-standard anchor schemes' do exp = %q{IRC} act = filter(exp) From d0d7dfb2f5c15f64746ff2df6f579bc06b3a80b3 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 14 Mar 2016 15:41:36 -0700 Subject: [PATCH 41/59] Bump gitlab_git to 9.0.3 --- CHANGELOG | 1 + Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 24f303fc219..d38646ece67 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ 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 diff --git a/Gemfile.lock b/Gemfile.lock index f719dd60e3f..9772e7fdd38 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -359,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) From 5960e9954934d367d02f76f51d31d62235d4aa89 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Tue, 15 Mar 2016 00:36:11 +0100 Subject: [PATCH 42/59] Remove allowed to fail by ignoring RedCloth --- .gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd013d50faa..1ba1d8d8468 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -150,11 +150,10 @@ bundler:audit: stage: test 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 From 8d8b457cebdfd0790157cd54fd1f24e46fbf0785 Mon Sep 17 00:00:00 2001 From: connorshea Date: Fri, 11 Mar 2016 12:33:43 -0700 Subject: [PATCH 43/59] Add SCSS Lint, CSSComb config file, run SCSS Lint in GitLab CI, add documentation for SCSS Style Guide. See !3069 for more information. --- .csscomb.json | 16 +++ .gitlab-ci.yml | 8 ++ .scss-lint.yml | 158 +++++++++++++++++++++++ CONTRIBUTING.md | 2 + Gemfile | 1 + Gemfile.lock | 4 + doc/development/scss_styleguide.md | 194 +++++++++++++++++++++++++++++ lib/tasks/scss-lint.rake | 10 ++ 8 files changed, 393 insertions(+) create mode 100644 .csscomb.json create mode 100644 .scss-lint.yml create mode 100644 doc/development/scss_styleguide.md create mode 100644 lib/tasks/scss-lint.rake diff --git a/.csscomb.json b/.csscomb.json new file mode 100644 index 00000000000..e353e6a63d0 --- /dev/null +++ b/.csscomb.json @@ -0,0 +1,16 @@ +{ + "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 +} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd013d50faa..b5f53725f95 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/.scss-lint.yml b/.scss-lint.yml new file mode 100644 index 00000000000..e350b2073c3 --- /dev/null +++ b/.scss-lint.yml @@ -0,0 +1,158 @@ +# 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30c97429040..7540fa1afcc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/ diff --git a/Gemfile b/Gemfile index 1550afb1b56..d022ac96bf4 100644 --- a/Gemfile +++ b/Gemfile @@ -286,6 +286,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 diff --git a/Gemfile.lock b/Gemfile.lock index d4e28db00d6..b69980af2ba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -1008,6 +1011,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) diff --git a/doc/development/scss_styleguide.md b/doc/development/scss_styleguide.md new file mode 100644 index 00000000000..6c48c25448b --- /dev/null +++ b/doc/development/scss_styleguide.md @@ -0,0 +1,194 @@ +# 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 diff --git a/lib/tasks/scss-lint.rake b/lib/tasks/scss-lint.rake new file mode 100644 index 00000000000..250fd8699e4 --- /dev/null +++ b/lib/tasks/scss-lint.rake @@ -0,0 +1,10 @@ +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 From 4fc2dcc4f479f0d2676004406b247548bfc64c86 Mon Sep 17 00:00:00 2001 From: Jacob Schatz Date: Mon, 14 Mar 2016 23:23:30 -0400 Subject: [PATCH 44/59] Fix bug with JS error from dropdowns in filter area --- app/assets/javascripts/breakpoints.coffee | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee index 1ffaaddb055..5457430f921 100644 --- a/app/assets/javascripts/breakpoints.coffee +++ b/app/assets/javascripts/breakpoints.coffee @@ -10,7 +10,6 @@ class @Breakpoints setup: -> allDeviceSelector = BREAKPOINTS.map (breakpoint) -> ".device-#{breakpoint}" - return if $(allDeviceSelector.join(",")).length # Create all the elements @@ -18,12 +17,17 @@ class @Breakpoints "
" $("body").append els.join('') - getBreakpointSize: -> + visibleDevice: -> allDeviceSelector = BREAKPOINTS.map (breakpoint) -> ".device-#{breakpoint}" + $(allDeviceSelector.join(",")).filter(":visible") - $visibleDevice = $(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: -> From 30b36c92c386e93b432166fb6f9dd973882a6d82 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Tue, 15 Mar 2016 11:03:43 +0100 Subject: [PATCH 45/59] Use an exception to pass messages --- app/controllers/projects_controller.rb | 15 ++++++++---- app/services/git_push_service.rb | 1 + app/services/projects/housekeeping_service.rb | 19 ++++++++------- spec/services/git_push_service_spec.rb | 24 ++++++++++++++----- .../projects/housekeeping_service_spec.rb | 6 ++--- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2a3dc5c79f7..36f37221c58 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -170,12 +170,17 @@ class ProjectsController < ApplicationController end def housekeeping - message = ::Projects::HousekeepingService.new(@project).execute + ::Projects::HousekeepingService.new(@project).execute - respond_to do |format| - flash[:notice] = message - 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 diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index e50cbdfb602..4313de0ccab 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -79,6 +79,7 @@ class GitPushService < BaseService housekeeping = Projects::HousekeepingService.new(@project) housekeeping.increment! housekeeping.execute if housekeeping.needed? + rescue Projects::HousekeepingService::LeaseTaken end def process_default_branch diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 83bdedf7a8d..bccd67d3dbf 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -11,20 +11,22 @@ module Projects 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 - if !try_obtain_lease - return "Housekeeping was already triggered in the past #{LEASE_TIMEOUT / 60} minutes" - end + raise LeaseTaken if !try_obtain_lease GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) - @project.pushes_since_gc = 0 - @project.save! - - "Housekeeping successfully started" + ensure + @project.update_column(:pushes_since_gc, 0) end def needed? @@ -32,8 +34,7 @@ module Projects end def increment! - @project.pushes_since_gc += 1 - @project.save! + @project.increment!(:pushes_since_gc) end private diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index ebf3ec1f5fd..145bc937560 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -402,7 +402,7 @@ describe GitPushService, services: true do end describe "housekeeping" do - let(:housekeeping) { instance_double('Projects::HousekeepingService', increment!: nil, needed?: false) } + let(:housekeeping) { Projects::HousekeepingService.new(project) } before do allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping) @@ -414,16 +414,28 @@ describe GitPushService, services: true do execute_service(project, user, @oldrev, @newrev, @ref) end - it 'performs housekeeping when needed' do - expect(housekeeping).to receive(:needed?).and_return(true) - expect(housekeeping).to receive(:execute) + context 'when housekeeping is needed' do + before do + allow(housekeeping).to receive(:needed?).and_return(true) + end - execute_service(project, user, @oldrev, @newrev, @ref) + 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 diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index 4c3577149f9..93bf1b81fbe 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -14,7 +14,7 @@ describe Projects::HousekeepingService do expect(subject).to receive(:try_obtain_lease).and_return(true) expect(GitlabShellWorker).to receive(:perform_async).with(:gc, project.path_with_namespace) - expect(subject.execute).to include('successfully started') + subject.execute expect(project.pushes_since_gc).to eq(0) end @@ -22,8 +22,8 @@ describe Projects::HousekeepingService do expect(subject).to receive(:try_obtain_lease).and_return(false) expect(GitlabShellWorker).not_to receive(:perform_async) - expect(subject.execute).to include('already triggered') - expect(project.pushes_since_gc).to eq(3) + expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken) + expect(project.pushes_since_gc).to eq(0) end end From c51c901916c9092a97f6a11f7bc64ad25536ea19 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 11:06:50 +0100 Subject: [PATCH 46/59] Return the external issue tracker even if it's null This solves the problem with caching the nil value with instance variable. Without this the every time we ask for external_issue_tracker we built AR and potentially do SQL query --- app/models/project.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/project.rb b/app/models/project.rb index 79e0cc7b23d..346cd6222cf 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -508,6 +508,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 From 76350e2ede187a8bd15e343c30537c90ee557aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 10 Mar 2016 17:15:14 +0100 Subject: [PATCH 47/59] Ensure "new SSH key" email do not ends up as dead Sidekiq jobs Related to #2235. This is done by: 1. Delaying the notification sending after the SSH key is commited in DB 2. Gracefully exit the mailer method if the record cannot be found --- CHANGELOG | 1 + app/mailers/emails/profile.rb | 5 ++++- app/models/key.rb | 3 ++- spec/mailers/emails/profile_spec.rb | 6 +++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d38646ece67..27c595585a9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -25,6 +25,7 @@ v 8.6.0 (unreleased) - 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 diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 3a83b083109..256cbcd73a1 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,10 @@ module Emails end def new_ssh_key_email(key_id) - @key = Key.find(key_id) + @key = Key.find_by_id(key_id) + + return unless @key + @current_user = @user = @key.user @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) diff --git a/app/models/key.rb b/app/models/key.rb index 406a1257b5d..0282ad18139 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -16,6 +16,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include AfterCommitQueue include Sortable belongs_to :user @@ -62,7 +63,7 @@ class Key < ActiveRecord::Base end def notify_user - NotificationService.new.new_key(self) + run_after_commit { NotificationService.new.new_key(self) } end def post_create_hook diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 5b575da34f3..c6758ccad39 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -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 From 090368f6c6edf5727b3e8c5ed69ccaa31e8a2c5f Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 15 Mar 2016 12:46:21 +0200 Subject: [PATCH 48/59] Windows CI support is full since Runner v1.0.0 [ci skip] --- doc/ci/yaml/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4d27c07913d..5158e3c387c 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -399,7 +399,7 @@ The above script will: >**Notes:** > > - Introduced in GitLab Runner v0.7.0 for non-Windows platforms. -> - Limited Windows support was added in GitLab Runner v.1.0.0. +> - 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. From c96e037dcbe127c6525058a7884fc7c5f2708979 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 14:26:15 +0100 Subject: [PATCH 49/59] Fix double borders around the CI status --- app/helpers/ci_status_helper.rb | 10 +++++++--- app/views/projects/ci/builds/_build.html.haml | 3 +-- .../_generic_commit_status.html.haml | 3 +-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index f20779f2fbb..391d74ebdbf 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -12,9 +12,13 @@ module CiStatusHelper ci_label_for_status(ci_commit.status) end - def ci_status_with_icon(status) - content_tag :span, class: "ci-status ci-#{status}" do - ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + def ci_status_with_icon(status, target = nil) + content = ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + clazz = "ci-status ci-#{status}" + if target + link_to content, target, class: clazz + else + content_tag :span, content, class: clazz end end diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 7123efffd5b..d22d1da8402 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -1,8 +1,7 @@ %tr.build %td.status - if can?(current_user, :read_build, build) - = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do - = ci_status_with_icon(build.status) + = ci_status_with_icon(build.status, namespace_project_build_url(build.project.namespace, build.project, build)) - else = ci_status_with_icon(build.status) diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 4143ea13063..c15386b4883 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -1,8 +1,7 @@ %tr.generic_commit_status %td.status - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url - = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do - = ci_status_with_icon(generic_commit_status.status) + = ci_status_with_icon(generic_commit_status.status, generic_commit_status.target_url) - else = ci_status_with_icon(generic_commit_status.status) From 1714883107b7b8b8f2ef8c2836acc2866362738e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 13:20:54 +0100 Subject: [PATCH 50/59] Revert "Merge branch 'avatar-cropping' into 'master' " This reverts commit 01160fc06182de89c400af174861f6545ad6ceb8, reversing changes made to 4bff9daf8b6d85e9c78565e21cfaa3f6d36f0282. --- CHANGELOG | 1 - Gemfile | 3 - Gemfile.lock | 2 - app/assets/javascripts/application.js.coffee | 1 - app/assets/javascripts/profile.js.coffee | 48 +- app/assets/stylesheets/application.scss | 1 - app/assets/stylesheets/framework/mixins.scss | 6 - app/assets/stylesheets/pages/profile.scss | 36 - app/controllers/profiles_controller.rb | 3 - app/models/user.rb | 8 - app/uploaders/avatar_uploader.rb | 11 - app/views/profiles/show.html.haml | 19 - features/steps/profile/profile.rb | 20 +- .../controllers/namespaces_controller_spec.rb | 2 +- .../profiles/avatars_controller_spec.rb | 2 +- spec/controllers/uploads_controller_spec.rb | 2 +- spec/factories/users.rb | 7 - spec/helpers/application_helper_spec.rb | 6 +- spec/models/user_spec.rb | 26 - vendor/assets/javascripts/cropper.js | 2972 ----------------- vendor/assets/stylesheets/cropper.css | 379 --- 21 files changed, 17 insertions(+), 3538 deletions(-) delete mode 100755 vendor/assets/javascripts/cropper.js delete mode 100755 vendor/assets/stylesheets/cropper.css diff --git a/CHANGELOG b/CHANGELOG index d38646ece67..039ef5587a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,7 +10,6 @@ v 8.6.0 (unreleased) 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) diff --git a/Gemfile b/Gemfile index 26b61c2fa5b..2f14b9965b2 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 9772e7fdd38..14b03bab5dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -469,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) @@ -957,7 +956,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) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index e9c6196e926..d415bbd3476 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -42,7 +42,6 @@ #= require jquery.nicescroll #= require_tree . #= require fuzzaldrin-plus -#= require cropper.js window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index 59d44c30bee..20f87440551 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -17,52 +17,14 @@ class @Profile $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - # Avatar management + $('.js-choose-user-avatar-button').bind "click", -> + form = $(this).closest("form") + form.find(".js-user-avatar-input").click() - $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() - - $avatarInput.on "change", -> + $('.js-user-avatar-input').bind "change", -> form = $(this).closest("form") filename = $(this).val().replace(/^.*[\\\/]/, '') - $filename.data('label', $filename.text()).text(filename) - - reader = new FileReader - - reader.onload = (event) -> - $modalCrop.modal('show') - $modalCropImg.attr('src', event.target.result) - - fileData = reader.readAsDataURL(this.files[0]) + form.find(".js-avatar-filename").text(filename) $ -> # Extract the SSH Key title from its comment diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e2d590f4df4..2d301d21ab9 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,7 +9,6 @@ *= require_self *= require dropzone/basic *= require cal-heatmap - *= require cropper.css */ /* diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 368bbfe5355..1d5000fe388 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -41,12 +41,6 @@ transition: $transition; } -@mixin transform($transform) { - -webkit-transform: $transform; - -ms-transform: $transform; - transform: $transform; -} - /** * Prefilled mixins * Mixins with fixed values diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 248c56e459d..13069a25316 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -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) { diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a1148961..28803164fcf 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -65,9 +65,6 @@ class ProfilesController < Profiles::ApplicationController def user_params params.require(:user).permit( - :avatar_crop_x, - :avatar_crop_y, - :avatar_crop_size, :avatar, :bio, :email, diff --git a/app/models/user.rb b/app/models/user.rb index 8871b0ab9fa..68b242888aa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -98,9 +98,6 @@ class User < ActiveRecord::Base # Virtual attribute for authenticating by either username or email attr_accessor :login - # Virtual attributes to define avatar cropping - attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size - # # Relations # @@ -166,11 +163,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 diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 2c72df44ff0..6135c3ad96f 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -2,22 +2,11 @@ class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper - include CarrierWave::MiniMagick storage :file after :store, :reset_events_cache - process :cropper - - def cropper - return unless model.respond_to?(:avatar_crop_size) && model.valid? - - manipulate! do |img| - img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}" - end - end - def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 3d1ba49491c..cd582ba7060 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,7 +1,4 @@ = form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f| - = f.hidden_field :avatar_crop_x - = f.hidden_field :avatar_crop_y - = f.hidden_field :avatar_crop_size -if @user.errors.any? %div.alert.alert-danger %ul @@ -97,19 +94,3 @@ .prepend-top-default.append-bottom-default = f.submit 'Update profile settings', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" - -.modal.modal-profile-crop - .modal-dialog - .modal-content - .modal-header - %button.close{type: 'button', data: {dismiss: 'modal'}} - %span - × - %h4.modal-title - Crop your new profile picture - .modal-body - %p - %img.modal-profile-crop-image - .modal-footer - %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} - Set new profile picture diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index d9436e9e21a..bf9534be8be 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -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 @@ -229,16 +233,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 diff --git a/spec/controllers/namespaces_controller_spec.rb b/spec/controllers/namespaces_controller_spec.rb index d4a380cc2ee..77436958711 100644 --- a/spec/controllers/namespaces_controller_spec.rb +++ b/spec/controllers/namespaces_controller_spec.rb @@ -1,7 +1,7 @@ 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 diff --git a/spec/controllers/profiles/avatars_controller_spec.rb b/spec/controllers/profiles/avatars_controller_spec.rb index 85dff009bcf..ad5855df0a4 100644 --- a/spec/controllers/profiles/avatars_controller_spec.rb +++ b/spec/controllers/profiles/avatars_controller_spec.rb @@ -1,7 +1,7 @@ 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) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 0d9f4b299bc..af5d043cf02 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,7 +1,7 @@ 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 diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 785c2a3d811..a5c60c51c5b 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -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' diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8013b31524f..f6c1005d265 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -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") diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 909b6796591..6290ab3ebec 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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 diff --git a/vendor/assets/javascripts/cropper.js b/vendor/assets/javascripts/cropper.js deleted file mode 100755 index 84aa6119ec3..00000000000 --- a/vendor/assets/javascripts/cropper.js +++ /dev/null @@ -1,2972 +0,0 @@ -/*! - * 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($('')[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 = $('')[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 = $(''); - - 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(''); - 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( - '' - ); - }); - }, - - 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 = $('')[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 = ( - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - '
' - ); - - // 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; - }; - -}); diff --git a/vendor/assets/stylesheets/cropper.css b/vendor/assets/stylesheets/cropper.css deleted file mode 100755 index 41ee4bd546c..00000000000 --- a/vendor/assets/stylesheets/cropper.css +++ /dev/null @@ -1,379 +0,0 @@ -/*! - * 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; -} From a7f74e033f89b3111c6f297f9880bb2b22a1a15a Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Tue, 15 Mar 2016 15:38:12 +0100 Subject: [PATCH 51/59] bundle:audit job only on master --- .gitlab-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd013d50faa..515eb856113 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,6 +148,8 @@ flay: bundler:audit: stage: test + only: + - master script: - "bundle exec bundle-audit update" - "bundle exec bundle-audit check" @@ -162,7 +164,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 From 89e50a0264c4651b9c2f4ac2981b9b1d95de2875 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 16:08:32 +0100 Subject: [PATCH 52/59] Use klass instead of clazz --- app/helpers/ci_status_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 391d74ebdbf..8b1575d5e0c 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -14,11 +14,11 @@ module CiStatusHelper def ci_status_with_icon(status, target = nil) content = ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) - clazz = "ci-status ci-#{status}" + klass = "ci-status ci-#{status}" if target - link_to content, target, class: clazz + link_to content, target, class: klass else - content_tag :span, content, class: clazz + content_tag :span, content, class: klass end end From c742760289e51117d3e76e27a626691bec631e1e Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Tue, 15 Mar 2016 16:46:17 +0100 Subject: [PATCH 53/59] Ignore eager loading in Project.search UNION The queries that are UNION'd together don't need any eager loading (since we really only use the resulting SQL instead of having ActiveRecord actually run the queries). By dropping any eager loaded associations queries such as the following work instead of producing a SQL error: Project.all.includes(:namespace).search('foo') --- app/models/project.rb | 7 +++++++ spec/models/project_spec.rb | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/project.rb b/app/models/project.rb index 79e0cc7b23d..d246d9e3c7e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -286,7 +286,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)) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 59c5ffa6b9c..b8b9a455b83 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -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 From 0444fa560acd07255960284f19b1de6499cd5910 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 12 Feb 2016 20:28:39 +0530 Subject: [PATCH 54/59] Original implementation to allow users to subscribe to labels 1. Allow subscribing (the current user) to a label - Refactor the `Subscription` coffeescript class - The main change is that it accepts a container, and conducts all DOM queries within its scope. We need this because the labels page has multiple instances of `Subscription` on the same page. 2. Creating an issue or MR with labels notifies users subscribed to those labels - Label `has_many` subscribers through subscriptions. 3. Adding a label to an issue or MR notifies users subscribed to those labels - This only applies to subscribers of the label that has just been added, not all labels for the issue. --- CHANGELOG | 2 + app/assets/javascripts/subscription.js.coffee | 32 +++---- app/assets/stylesheets/pages/labels.scss | 4 + app/controllers/projects/labels_controller.rb | 9 +- app/mailers/emails/issues.rb | 11 ++- app/mailers/emails/merge_requests.rb | 14 ++- app/models/concerns/issuable.rb | 30 ++----- app/models/concerns/subscribable.rb | 43 +++++++++ app/models/label.rb | 2 + app/services/issuable_base_service.rb | 2 +- app/services/issues/update_service.rb | 7 +- app/services/merge_requests/update_service.rb | 7 +- app/services/notification_service.rb | 44 ++++++++++ .../_relabeled_issuable_email.html.haml | 5 ++ .../notify/_relabeled_issuable_email.text.erb | 5 ++ .../notify/relabeled_issue_email.html.haml | 1 + .../notify/relabeled_issue_email.text.erb | 1 + .../relabeled_merge_request_email.html.haml | 1 + .../relabeled_merge_request_email.text.erb | 1 + app/views/projects/labels/_label.html.haml | 12 +++ app/views/shared/issuable/_sidebar.html.haml | 4 +- config/routes.rb | 4 + features/project/labels.feature | 16 ++++ features/steps/project/labels.rb | 34 ++++++++ spec/factories/labels.rb | 2 +- spec/models/concerns/subscribable_spec.rb | 16 ++++ spec/services/issues/update_service_spec.rb | 56 +++++++++++- .../merge_requests/update_service_spec.rb | 56 +++++++++++- spec/services/notification_service_spec.rb | 87 ++++++++++++++++--- spec/spec_helper.rb | 1 + spec/support/email_helpers.rb | 13 +++ 31 files changed, 458 insertions(+), 64 deletions(-) create mode 100644 app/models/concerns/subscribable.rb create mode 100644 app/views/notify/_relabeled_issuable_email.html.haml create mode 100644 app/views/notify/_relabeled_issuable_email.text.erb create mode 100644 app/views/notify/relabeled_issue_email.html.haml create mode 100644 app/views/notify/relabeled_issue_email.text.erb create mode 100644 app/views/notify/relabeled_merge_request_email.html.haml create mode 100644 app/views/notify/relabeled_merge_request_email.text.erb create mode 100644 features/project/labels.feature create mode 100644 features/steps/project/labels.rb create mode 100644 spec/models/concerns/subscribable_spec.rb create mode 100644 spec/support/email_helpers.rb diff --git a/CHANGELOG b/CHANGELOG index 015efa05c6a..fb9500e68a0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -174,6 +174,8 @@ v 8.5.0 v 8.4.5 - No CE-specific changes + - Allow user subscription to a label; get notified for issues/merge requests related to that label. + - Allow user subscription to a label; get notified for issues/merge requests related to that label. (Timothy Andrew) v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee index 7f41616d4e7..afc17338b26 100644 --- a/app/assets/javascripts/subscription.js.coffee +++ b/app/assets/javascripts/subscription.js.coffee @@ -1,17 +1,19 @@ 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: (@url, container) -> + @subscribe_button = $(container).find(".subscribe-button") + @subscription_status = $(container).find(".subscription-status") + @subscribe_button.unbind("click").click(@toggleSubscription) - + toggleSubscription: (event) => + btn = $(event.currentTarget) + action = btn.find("span").text() + current_status = @subscription_status.attr("data-status") + btn.prop("disabled", true) + + $.post @url, => + btn.prop("disabled", false) + status = if current_status == "subscribed" then "unsubscribed" else "subscribed" + @subscription_status.attr("data-status", status) + action = if status == "subscribed" then "Unsubscribe" else "Subscribe" + btn.find("span").text(action) + @subscription_status.find(">div").toggleClass("hidden") diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 5ec0966194c..1791ba9fbd8 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -41,3 +41,7 @@ .color-label { padding: 3px 4px; } + +.label-subscription { + display: inline-block; +} \ No newline at end of file diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index ecac3c395ec..d0334c37b67 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -1,8 +1,8 @@ class Projects::LabelsController < Projects::ApplicationController before_action :module_enabled - before_action :label, only: [:edit, :update, :destroy] + before_action :label, only: [:edit, :update, :destroy, :toggle_subscription] before_action :authorize_read_label! - before_action :authorize_admin_labels!, except: [:index] + before_action :authorize_admin_labels!, except: [:index, :toggle_subscription] respond_to :js, :html @@ -60,6 +60,11 @@ class Projects::LabelsController < Projects::ApplicationController end end + def toggle_subscription + @label.toggle_subscription(current_user) + render nothing: true + end + protected def module_enabled diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 4a88cb61132..2838baa1b4e 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -16,7 +16,14 @@ 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, updated_by_user_id, label_names) + setup_issue_mail(issue_id, recipient_id) + @label_names = label_names + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end @@ -24,7 +31,7 @@ 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 diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 325996e2e16..680ce975c79 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -19,10 +19,20 @@ module Emails subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) end + def relabeled_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, label_names) + setup_merge_request_mail(merge_request_id, recipient_id) + @label_names = label_names + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, + from: sender(@merge_request.author_id), + to: recipient(recipient_id), + subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + 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 + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@merge_request, from: sender(updated_by_user_id), to: recipient(recipient_id), @@ -42,7 +52,7 @@ module Emails setup_merge_request_mail(merge_request_id, recipient_id) @mr_status = status - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@merge_request, from: sender(updated_by_user_id), to: recipient(recipient_id), diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3c42f582937..affc4a842a7 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -8,6 +8,7 @@ module Issuable extend ActiveSupport::Concern include Participable include Mentionable + include Subscribable include StripAttribute included do @@ -18,7 +19,6 @@ module Issuable has_many :notes, as: :noteable, dependent: :destroy has_many :label_links, as: :target, dependent: :destroy has_many :labels, through: :label_links - has_many :subscriptions, dependent: :destroy, as: :subscribable validates :author, presence: true validates :title, presence: true, length: { within: 0..255 } @@ -149,28 +149,6 @@ 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 - - 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, @@ -201,6 +179,12 @@ module Issuable end end + # Labels that are currently applied to this object + # that are not present in `old_labels` + def added_labels(old_labels) + self.labels - old_labels + end + # Convert this Issuable class name to a format usable by Ability definitions # # Examples: diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 00000000000..cab9241ac3d --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,43 @@ +# == 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) + subscription = subscriptions.find_by_user_id(user.id) + + if subscription + return subscription.subscribed + end + + # FIXME + # Issue/MergeRequest has participants, but Label doesn't. + # Ideally, subscriptions should be separate from participations, + # but that seems like a larger change with farther-reaching + # consequences, so this is a compromise for the time being. + if respond_to?(:participants) + participants(user).include?(user) + end + end + + def toggle_subscription(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: !subscribed?(user)) + end + + def unsubscribe(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: false) + end +end diff --git a/app/models/label.rb b/app/models/label.rb index 5ff644b8426..f7ffc0b7f36 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -14,6 +14,8 @@ class Label < ActiveRecord::Base include Referable + include Subscribable + # Represents a "No Label" state used for filtering Issues and Merge # Requests that have no label assigned. LabelStruct = Struct.new(:title, :name) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index ca87dca4a70..971c074882e 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -95,7 +95,7 @@ class IssuableBaseService < BaseService old_labels = options[:old_labels] if old_labels && (issuable.labels != old_labels) - create_labels_note(issuable, issuable.labels - old_labels, old_labels - issuable.labels) + create_labels_note(issuable, issuable.added_labels(old_labels), old_labels - issuable.labels) end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 51ef9dfe610..b2e63a4e1af 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,7 +4,7 @@ module Issues update(issue) end - def handle_changes(issue, options = {}) + def handle_changes(issue, old_labels: [], new_labels: []) if has_changes?(issue, options) 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 + + new_labels = issue.added_labels(old_labels) + if new_labels.present? + notification_service.relabeled_issue(issue, new_labels, current_user) + end end def reopen_service diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6319ad805b6..6fd569dc302 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -14,7 +14,7 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, options = {}) + def handle_changes(issue, old_labels: [], new_labels: []) if has_changes?(merge_request, options) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -44,6 +44,11 @@ module MergeRequests merge_request.previous_changes.include?('source_branch') merge_request.mark_as_unchecked end + + new_labels = merge_request.added_labels(old_labels) + if new_labels.present? + notification_service.relabeled_merge_request(merge_request, new_labels, current_user) + end end def reopen_service diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ca8a41d93b8..e9955cd3e2d 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -52,6 +52,14 @@ class NotificationService reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email') end + # When we change labels on an issue we should send emails. + # + # We pass in the labels, here, because we only want the labels that + # have been *added* during this relabel, not all of them. + def relabeled_issue(issue, labels, current_user) + relabel_resource_email(issue, issue.project, labels, current_user, 'relabeled_issue_email') + end + # When create a merge request we should send next emails: # @@ -70,6 +78,14 @@ class NotificationService reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email') end + # When we change labels on a merge request we should send emails. + # + # We pass in the labels, here, because we only want the labels that + # have been *added* during this relabel, not all of them. + def relabeled_merge_request(merge_request, labels, current_user) + relabel_resource_email(merge_request, merge_request.project, 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 @@ -142,6 +158,7 @@ class NotificationService recipients = reject_muted_users(recipients, note.project) recipients = add_subscribed_users(recipients, note.noteable) + recipients = add_label_subscriptions(recipients, note.noteable) recipients = reject_unsubscribed_users(recipients, note.noteable) recipients.delete(note.author) @@ -359,6 +376,16 @@ class NotificationService end end + def add_label_subscriptions(recipients, target) + return recipients unless target.respond_to? :labels + + target.labels.each do |label| + recipients += label.subscriptions.where(subscribed: true).map(&:user) + end + + recipients + end + def new_resource_email(target, project, method) recipients = build_recipients(target, project, target.author) @@ -392,6 +419,15 @@ class NotificationService end end + def relabel_resource_email(target, project, labels, current_user, method) + recipients = build_relabel_recipients(target, project, labels, current_user) + label_names = labels.map(&:name) + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, current_user.id, label_names).deliver_later + end + end + def reopen_resource_email(target, project, current_user, method, status) recipients = build_recipients(target, project, current_user) @@ -416,6 +452,7 @@ class NotificationService recipients = reject_muted_users(recipients, project) recipients = add_subscribed_users(recipients, target) + recipients = add_label_subscriptions(recipients, target) recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) @@ -423,6 +460,13 @@ class NotificationService recipients.uniq end + def build_relabel_recipients(target, project, labels, current_user) + recipients = add_label_subscriptions([], target) + recipients = reject_unsubscribed_users(recipients, target) + recipients.delete(current_user) + recipients.uniq + end + def mailer Notify end diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml new file mode 100644 index 00000000000..a41ff07c306 --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -0,0 +1,5 @@ +%p + #{@updated_by.name} added the + %em= @label_names.to_sentence + #{"label".pluralize(@label_names.count)} to #{issuable.class.model_name.human} #{issuable.iid}. + diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb new file mode 100644 index 00000000000..1a28d7fd352 --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -0,0 +1,5 @@ +<%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> was relabeled. + +Issue <%= issuable.iid %>: <%= url_for(namespace_project_issue_url(issuable.project.namespace, issuable.project, issuable)) %> +Author: <%= issuable.author_name %> +New Labels: <%= @label_names.to_sentence %> diff --git a/app/views/notify/relabeled_issue_email.html.haml b/app/views/notify/relabeled_issue_email.html.haml new file mode 100644 index 00000000000..a3e094e86eb --- /dev/null +++ b/app/views/notify/relabeled_issue_email.html.haml @@ -0,0 +1 @@ += render "relabeled_issuable_email", issuable: @issue diff --git a/app/views/notify/relabeled_issue_email.text.erb b/app/views/notify/relabeled_issue_email.text.erb new file mode 100644 index 00000000000..de7f04a323a --- /dev/null +++ b/app/views/notify/relabeled_issue_email.text.erb @@ -0,0 +1 @@ +<%= render "relabeled_issuable_email", issuable: @issue %> diff --git a/app/views/notify/relabeled_merge_request_email.html.haml b/app/views/notify/relabeled_merge_request_email.html.haml new file mode 100644 index 00000000000..3937b449a37 --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.html.haml @@ -0,0 +1 @@ += render "relabeled_issuable_email", issuable: @merge_request diff --git a/app/views/notify/relabeled_merge_request_email.text.erb b/app/views/notify/relabeled_merge_request_email.text.erb new file mode 100644 index 00000000000..5c4d86e4dc2 --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= render "relabeled_issuable_email", issuable: @merge_request %> diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index f7ddd30c5a9..3b14a925c46 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -10,6 +10,18 @@ = link_to_label(label) do = pluralize label.open_issues_count, 'open issue' + - if current_user + %div{class: "label-subscription", data: {id: label.id}} + - subscribed = label.subscribed?(current_user) + - subscription_status = subscribed ? 'subscribed' : 'unsubscribed' + .subscription-status{data: {status: subscription_status}} + %button.btn.btn-sm.btn-info.subscribe-button{:type => 'button'} + %span= subscribed ? 'Unsubscribe' : 'Subscribe' + - 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?"} + +:javascript + new Subscription("#{toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}", + ".label-subscription[data-id='#{label.id}']"); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9020a1330a3..b44086d4205 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -98,7 +98,7 @@ %hr - if current_user - subscribed = issuable.subscribed?(current_user) - .block.light + .block.light.subscription .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("#{toggle_subscription_path(issuable)}", ".subscription"); new IssuableContext(); diff --git a/config/routes.rb b/config/routes.rb index 780ad757c5b..2ae282f48a6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/features/project/labels.feature b/features/project/labels.feature new file mode 100644 index 00000000000..cd3f3a789f0 --- /dev/null +++ b/features/project/labels.feature @@ -0,0 +1,16 @@ +@labels +Feature: Labels + Background: + Given I sign in as a user + And I own project "Shop" + And I visit project "Shop" issues page + And project "Shop" has labels: "bug", "feature", "enhancement" + + @javascript + Scenario: I can subscribe to a label + When I visit project "Shop" labels page + Then I should see that I am unsubscribed + When I click button "Subscribe" + Then I should see that I am subscribed + When I click button "Unsubscribe" + Then I should see that I am unsubscribed diff --git a/features/steps/project/labels.rb b/features/steps/project/labels.rb new file mode 100644 index 00000000000..3f800a10594 --- /dev/null +++ b/features/steps/project/labels.rb @@ -0,0 +1,34 @@ +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' do + expect(subscribe_button).to have_content 'Unsubscribe' + end + + step 'I should see that I am unsubscribed' do + expect(subscribe_button).to have_content 'Subscribe' + end + + step 'I click button "Unsubscribe"' do + subscribe_button.click + end + + step 'I click button "Subscribe"' do + subscribe_button.click + end + + private + + def subscribe_button + first('.subscribe-button span') + end +end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 6e70af10af3..91a7400afa1 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -13,7 +13,7 @@ FactoryGirl.define do factory :label do - title "Bug" + title { FFaker::Color.name } color "#990000" project end diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb new file mode 100644 index 00000000000..9ee60426a5d --- /dev/null +++ b/spec/models/concerns/subscribable_spec.rb @@ -0,0 +1,16 @@ +require "spec_helper" + +describe Subscribable, "Subscribable" do + let(:resource) { create(:issue) } + let(:user) { create(:user) } + + describe "#subscribed?" do + it do + expect(resource.subscribed?(user)).to be_falsey + resource.toggle_subscription(user) + expect(resource.subscribed?(user)).to be_truthy + resource.toggle_subscription(user) + expect(resource.subscribed?(user)).to be_falsey + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index e579e49dfa7..dc9d8329751 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -48,7 +48,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 +148,60 @@ describe Issues::UpdateService, services: true do end end + context "when the issue is relabeled" do + it "sends notifications for subscribers of newly added labels" do + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + @issue.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does send notifications for existing labels" do + second_label = create(:label) + issue.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id, second_label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + @issue.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does not send notifications for removed labels" do + second_label = create(:label) + issue.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [second_label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + @issue.reload + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + context 'when Issue has tasks' do before { update_issue({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 99703c7a8ec..104e63ccfee 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -53,7 +53,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 +176,60 @@ describe MergeRequests::UpdateService, services: true do end end + context "when the merge request is relabeled" do + it "sends notifications for subscribers of newly added labels" do + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + @merge_request.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does send notifications for existing labels" do + second_label = create(:label) + merge_request.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id, second_label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + @merge_request.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does not send notifications for removed labels" do + second_label = create(:label) + merge_request.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [second_label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + @merge_request.reload + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + context 'when MergeRequest has tasks' do before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 2d0b5df4224..35afa768057 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -224,6 +224,16 @@ describe NotificationService, services: true do should_not_email(issue.assignee) end + + it "should email subscribers of the issue's labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label, issues: [issue]) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.new_issue(issue, @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end end describe :reassigned_issue do @@ -326,6 +336,32 @@ describe NotificationService, services: true do should_not_email(@u_participating) end end + + describe :relabel_issue do + it "sends email to subscribers of the given labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label, issues: [issue]) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.relabeled_issue(issue, [label], @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + label = create(:label, issues: [issue]) + notification.relabeled_issue(issue, [label], @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) + end + end end describe 'Merge Requests' do @@ -349,6 +385,17 @@ describe NotificationService, services: true do should_not_email(@u_participating) should_not_email(@u_disabled) end + + it "should email subscribers of the MR's labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label) + merge_request.labels << label + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.new_merge_request(merge_request, @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end end describe :reassigned_merge_request do @@ -410,6 +457,34 @@ describe NotificationService, services: true do should_not_email(@u_disabled) end end + + describe :relabel_merge_request do + it "sends email to subscribers of the given labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label) + merge_request.labels << label + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.relabeled_merge_request(merge_request, [label], @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + label = create(:label) + merge_request.labels << label + notification.relabeled_merge_request(merge_request, [label], @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) + end + end end describe 'Projects' do @@ -467,16 +542,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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7d939ca7509..596d607f2a1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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 diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb new file mode 100644 index 00000000000..a85ab22ce36 --- /dev/null +++ b/spec/support/email_helpers.rb @@ -0,0 +1,13 @@ +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 From 0129f346ee7fe0d89c0b80b04afebe2c4faf4dc9 Mon Sep 17 00:00:00 2001 From: connorshea Date: Tue, 15 Mar 2016 10:42:16 -0600 Subject: [PATCH 55/59] Remove parentheses from if statement Otherwise Rubocop will give a warning. As mentioned in !3197. --- app/services/git_push_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 4313de0ccab..d840ab5e340 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -89,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 From ac8c6f24e3070c28ab6f246599c95fd8f3b7e3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 18:16:21 +0100 Subject: [PATCH 56/59] Add 8.5.6 CHANGELOG items [ci skip] --- CHANGELOG | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 015efa05c6a..ebf49cd2ac0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,9 @@ v 8.6.0 (unreleased) - Move group activity to separate page - 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 From 54ec7e959900493b6e9174bf4dfe09ed0afd1e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 1 Mar 2016 17:33:13 +0100 Subject: [PATCH 57/59] Improving the original label-subscribing implementation 1. Make the "subscribed" text in Issuable sidebar reflect the labels subscription status 2. Current user mut be logged-in to toggle issue/MR/label subscription --- CHANGELOG | 3 +- app/assets/javascripts/subscription.js.coffee | 28 ++-- app/assets/stylesheets/pages/labels.scss | 4 +- app/controllers/projects/issues_controller.rb | 2 + app/controllers/projects/labels_controller.rb | 2 + .../projects/merge_requests_controller.rb | 2 + app/helpers/labels_helper.rb | 8 ++ app/mailers/emails/issues.rb | 25 ++-- app/mailers/emails/merge_requests.rb | 55 ++++---- app/models/concerns/issuable.rb | 10 +- app/models/concerns/subscribable.rb | 25 ++-- app/models/subscription.rb | 2 +- app/services/issuable_base_service.rb | 17 ++- app/services/issues/update_service.rb | 10 +- app/services/merge_requests/update_service.rb | 14 +- app/services/notification_service.rb | 72 +++++----- app/views/layouts/notify.html.haml | 13 +- .../_reassigned_issuable_email.text.erb | 2 +- .../_relabeled_issuable_email.html.haml | 4 +- .../notify/_relabeled_issuable_email.text.erb | 6 +- .../notify/relabeled_issue_email.html.haml | 2 +- .../notify/relabeled_issue_email.text.erb | 2 +- .../relabeled_merge_request_email.html.haml | 2 +- .../relabeled_merge_request_email.text.erb | 2 +- app/views/projects/labels/_label.html.haml | 16 +-- app/views/shared/issuable/_sidebar.html.haml | 4 +- features/project/labels.feature | 13 +- features/steps/project/labels.rb | 8 +- spec/factories/labels.rb | 2 +- spec/mailers/notify_spec.rb | 56 ++++++++ spec/mailers/shared/notify.rb | 6 +- spec/models/concerns/issuable_spec.rb | 42 ++++++ spec/models/concerns/subscribable_spec.rb | 51 ++++++- spec/services/issues/update_service_spec.rb | 55 +++----- .../merge_requests/update_service_spec.rb | 55 +++----- spec/services/notification_service_spec.rb | 129 +++++++++--------- 36 files changed, 439 insertions(+), 310 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fb9500e68a0..e9e7c88d860 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.6.0 (unreleased) - 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 @@ -174,8 +175,6 @@ v 8.5.0 v 8.4.5 - No CE-specific changes - - Allow user subscription to a label; get notified for issues/merge requests related to that label. - - Allow user subscription to a label; get notified for issues/merge requests related to that label. (Timothy Andrew) v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee index afc17338b26..084f0e0dc65 100644 --- a/app/assets/javascripts/subscription.js.coffee +++ b/app/assets/javascripts/subscription.js.coffee @@ -1,19 +1,21 @@ class @Subscription - constructor: (@url, container) -> - @subscribe_button = $(container).find(".subscribe-button") - @subscription_status = $(container).find(".subscription-status") - @subscribe_button.unbind("click").click(@toggleSubscription) + 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) + 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") + btn.prop('disabled', false) + status = if current_status == 'subscribed' then 'unsubscribed' else 'subscribed' + @subscription_status.attr('data-status', status) + action = if status == 'subscribed' then 'Unsubscribe' else 'Subscribe' + btn.find('span').text(action) + @subscription_status.find('>div').toggleClass('hidden') diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 1791ba9fbd8..61ee34b695e 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -43,5 +43,5 @@ } .label-subscription { - display: inline-block; -} \ No newline at end of file + display: inline-block; +} diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 67faa1e4437..24a862814b3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -111,6 +111,8 @@ class Projects::IssuesController < Projects::ApplicationController end def toggle_subscription + return unless current_user + @issue.toggle_subscription(current_user) render nothing: true diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index d0334c37b67..e4dea6b065a 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -61,6 +61,8 @@ class Projects::LabelsController < Projects::ApplicationController end def toggle_subscription + return unless current_user + @label.toggle_subscription(current_user) render nothing: true end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 03ba289eb94..954ee55a211 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -234,6 +234,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def toggle_subscription + return unless current_user + @merge_request.toggle_subscription(current_user) render nothing: true diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 89a054289e8..4455dcd0e20 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -124,6 +124,14 @@ module LabelsHelper options_from_collection_for_select(grouped_labels, 'name', 'title', params[:label_name]) end + def label_subscription_status(label) + label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed' + end + + def label_subscription_toggle_button_text(label) + label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe' + end + # Required for Banzai::Filter::LabelReferenceFilter module_function :render_colored_label, :render_colored_cross_project_label, :text_color_for_bg, :escape_once diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 2838baa1b4e..160b6df0b97 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -20,10 +20,11 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def relabeled_issue_email(recipient_id, issue_id, updated_by_user_id, label_names) - setup_issue_mail(issue_id, recipient_id) + def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id, sent_notification: false) + @label_names = label_names - @updated_by = User.find(updated_by_user_id) + @labels_url = namespace_project_labels_url(@project.namespace, @project) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end @@ -37,6 +38,16 @@ module Emails private + def setup_issue_mail(issue_id, recipient_id, sent_notification: true) + @issue = Issue.find(issue_id) + @project = @issue.project + @target_url = namespace_project_issue_url(@project.namespace, @project, @issue) + + if sent_notification + @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) + end + end + def issue_thread_options(sender_id, recipient_id) { from: sender(sender_id), @@ -44,13 +55,5 @@ module Emails subject: subject("#{@issue.title} (##{@issue.iid})") } end - - def setup_issue_mail(issue_id, recipient_id) - @issue = Issue.find(issue_id) - @project = @issue.project - @target_url = namespace_project_issue_url(@project.namespace, @project, @issue) - - @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) - end end end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 680ce975c79..334bad4e2f8 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -3,49 +3,35 @@ 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, updated_by_user_id, label_names) - setup_merge_request_mail(merge_request_id, recipient_id) + 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, sent_notification: false) + @label_names = label_names - @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, - from: sender(@merge_request.author_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + @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})")) + 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) @@ -53,22 +39,27 @@ module Emails @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})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end private - def setup_merge_request_mail(merge_request_id, recipient_id) + def setup_merge_request_mail(merge_request_id, recipient_id, sent_notification: true) @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) + if sent_notification + @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) + end + end + + def merge_request_thread_options(sender_id, recipient_id) + { + from: sender(sender_id), + to: recipient(recipient_id), + subject: subject("#{@merge_request.title} (##{@merge_request.iid})") + } end end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index affc4a842a7..86ab84615ba 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -149,6 +149,10 @@ module Issuable notes.awards.where(note: "thumbsup").count end + def subscribed_without_subscriptions?(user) + participants(user).include?(user) + end + def to_hook_data(user) hook_data = { object_kind: self.class.name.underscore, @@ -179,12 +183,6 @@ module Issuable end end - # Labels that are currently applied to this object - # that are not present in `old_labels` - def added_labels(old_labels) - self.labels - old_labels - end - # Convert this Issuable class name to a format usable by Ability definitions # # Examples: diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index cab9241ac3d..d5a881b2445 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -13,20 +13,21 @@ module Subscribable end def subscribed?(user) - subscription = subscriptions.find_by_user_id(user.id) - - if subscription - return subscription.subscribed + if subscription = subscriptions.find_by_user_id(user.id) + subscription.subscribed + else + subscribed_without_subscriptions?(user) end + end - # FIXME - # Issue/MergeRequest has participants, but Label doesn't. - # Ideally, subscriptions should be separate from participations, - # but that seems like a larger change with farther-reaching - # consequences, so this is a compromise for the time being. - if respond_to?(:participants) - participants(user).include?(user) - 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) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index dd75d3ab8ba..dd800ce110f 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -15,7 +15,7 @@ class Subscription < ActiveRecord::Base belongs_to :user belongs_to :subscribable, polymorphic: true - validates :user_id, + validates :user_id, uniqueness: { scope: [:subscribable_id, :subscribable_type] }, presence: true end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 971c074882e..18f76d3f650 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -11,7 +11,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, issuable.milestone) end - def create_labels_note(issuable, added_labels, removed_labels) + def create_labels_note(issuable, old_labels) + added_labels = issuable.labels - old_labels + removed_labels = old_labels - issuable.labels + SystemNoteService.change_label( issuable, issuable.project, current_user, added_labels, removed_labels) end @@ -71,20 +74,19 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, options = {}) + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| issuable.previous_changes.include?(attr.to_s) end - old_labels = options[:old_labels] - labels_changed = old_labels && issuable.labels != old_labels + labels_changed = issuable.labels != old_labels attrs_changed || labels_changed end - def handle_common_system_notes(issuable, options = {}) + def handle_common_system_notes(issuable, old_labels: []) if issuable.previous_changes.include?('title') create_title_change_note(issuable, issuable.previous_changes['title'].first) end @@ -93,9 +95,6 @@ class IssuableBaseService < BaseService create_task_status_note(issuable) end - old_labels = options[:old_labels] - if old_labels && (issuable.labels != old_labels) - create_labels_note(issuable, issuable.added_labels(old_labels), old_labels - issuable.labels) - end + create_labels_note(issuable, old_labels) if issuable.labels != old_labels end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b2e63a4e1af..3563cbaa997 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,8 +4,8 @@ module Issues update(issue) end - def handle_changes(issue, old_labels: [], new_labels: []) - 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 @@ -24,9 +24,9 @@ module Issues todo_service.reassigned_issue(issue, current_user) end - new_labels = issue.added_labels(old_labels) - if new_labels.present? - notification_service.relabeled_issue(issue, new_labels, current_user) + added_labels = issue.labels - old_labels + if added_labels.present? + notification_service.relabeled_issue(issue, added_labels, current_user) end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6fd569dc302..477c64e7377 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -14,8 +14,8 @@ module MergeRequests update(merge_request) end - def handle_changes(issue, old_labels: [], new_labels: []) - 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 @@ -45,9 +45,13 @@ module MergeRequests merge_request.mark_as_unchecked end - new_labels = merge_request.added_labels(old_labels) - if new_labels.present? - notification_service.relabeled_merge_request(merge_request, new_labels, current_user) + added_labels = merge_request.labels - old_labels + if added_labels.present? + notification_service.relabeled_merge_request( + merge_request, + added_labels, + current_user + ) end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e9955cd3e2d..19a6779dea9 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -24,16 +24,17 @@ class NotificationService end end - # When create an issue we should send next emails: + # When create an issue we should send an email to: # # * issue assignee if their notification level is not Disabled # * project team members with notification level higher then Participating + # * watchers of the issue's labels # def new_issue(issue, current_user) new_resource_email(issue, issue.project, 'new_issue_email') end - # When we close an issue we should send next emails: + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled # * issue assignee if their notification level is not Disabled @@ -43,7 +44,7 @@ class NotificationService close_resource_email(issue, issue.project, current_user, 'closed_issue_email') end - # When we reassign an issue we should send next emails: + # When we reassign an issue we should send an email to: # # * issue old assignee if their notification level is not Disabled # * issue new assignee if their notification level is not Disabled @@ -52,24 +53,25 @@ class NotificationService reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email') end - # When we change labels on an issue we should send emails. + # When we add labels to an issue we should send an email to: # - # We pass in the labels, here, because we only want the labels that - # have been *added* during this relabel, not all of them. - def relabeled_issue(issue, labels, current_user) - relabel_resource_email(issue, issue.project, labels, current_user, 'relabeled_issue_email') + # * 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 @@ -78,12 +80,12 @@ class NotificationService reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email') end - # When we change labels on a merge request we should send emails. + # When we add labels to a merge request we should send an email to: # - # We pass in the labels, here, because we only want the labels that - # have been *added* during this relabel, not all of them. - def relabeled_merge_request(merge_request, labels, current_user) - relabel_resource_email(merge_request, merge_request.project, labels, current_user, 'relabeled_merge_request_email') + # * 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) @@ -107,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 @@ -158,7 +161,6 @@ class NotificationService recipients = reject_muted_users(recipients, note.project) recipients = add_subscribed_users(recipients, note.noteable) - recipients = add_label_subscriptions(recipients, note.noteable) recipients = reject_unsubscribed_users(recipients, note.noteable) recipients.delete(note.author) @@ -365,29 +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 - - if subscriptions.any? - recipients + subscriptions.where(subscribed: true).map(&:user) - else - recipients - end + recipients + target.subscribers end - def add_label_subscriptions(recipients, target) + def add_labels_subscribers(recipients, target, labels: nil) return recipients unless target.respond_to? :labels - target.labels.each do |label| - recipients += label.subscriptions.where(subscribed: true).map(&:user) + (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 @@ -419,12 +415,12 @@ class NotificationService end end - def relabel_resource_email(target, project, labels, current_user, method) - recipients = build_relabel_recipients(target, project, labels, current_user) + 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, current_user.id, label_names).deliver_later + mailer.send(method, recipient.id, target.id, label_names, current_user.id).deliver_later end end @@ -452,7 +448,11 @@ class NotificationService recipients = reject_muted_users(recipients, project) recipients = add_subscribed_users(recipients, target) - recipients = add_label_subscriptions(recipients, target) + + if action == :new + recipients = add_labels_subscribers(recipients, target) + end + recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) @@ -460,8 +460,8 @@ class NotificationService recipients.uniq end - def build_relabel_recipients(target, project, labels, current_user) - recipients = add_label_subscriptions([], target) + 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 diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 325c68c69dc..37b4d562966 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -42,12 +42,15 @@ - else #{link_to "View it on GitLab", @target_url}. %br - -# Don't link the host is the line below, one link in the email is easier to quickly click than two. + -# Don't link the host in the line below, one link in the email is easier to quickly click than two. You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. If you'd like to receive fewer emails, you can - - if @sent_notification && @sent_notification.unsubscribable? - = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) - from this thread or - adjust your notification settings. + - if @labels_url + adjust your #{link_to 'label subscriptions', @labels_url}. + - else + - if @sent_notification && @sent_notification.unsubscribable? + = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) + from this thread or + adjust your notification settings. = email_action @target_url diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb index 855d37429d9..daf20a226dd 100644 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ b/app/views/notify/_reassigned_issuable_email.text.erb @@ -1,6 +1,6 @@ Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, {only_path: false}]) %> +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml index a41ff07c306..80a0de255be 100644 --- a/app/views/notify/_relabeled_issuable_email.html.haml +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -1,5 +1,3 @@ %p - #{@updated_by.name} added the + #{'Label'.pluralize(@label_names.size)} added: %em= @label_names.to_sentence - #{"label".pluralize(@label_names.count)} to #{issuable.class.model_name.human} #{issuable.iid}. - diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb index 1a28d7fd352..6a83d79fd61 100644 --- a/app/views/notify/_relabeled_issuable_email.text.erb +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -1,5 +1,3 @@ -<%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> was relabeled. +<%= 'Label'.pluralize(@label_names.size) %> added: <%= @label_names.to_sentence %> -Issue <%= issuable.iid %>: <%= url_for(namespace_project_issue_url(issuable.project.namespace, issuable.project, issuable)) %> -Author: <%= issuable.author_name %> -New Labels: <%= @label_names.to_sentence %> +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> diff --git a/app/views/notify/relabeled_issue_email.html.haml b/app/views/notify/relabeled_issue_email.html.haml index a3e094e86eb..b17b16e1814 100644 --- a/app/views/notify/relabeled_issue_email.html.haml +++ b/app/views/notify/relabeled_issue_email.html.haml @@ -1 +1 @@ -= render "relabeled_issuable_email", issuable: @issue += render 'relabeled_issuable_email', issuable: @issue diff --git a/app/views/notify/relabeled_issue_email.text.erb b/app/views/notify/relabeled_issue_email.text.erb index de7f04a323a..eeced97f601 100644 --- a/app/views/notify/relabeled_issue_email.text.erb +++ b/app/views/notify/relabeled_issue_email.text.erb @@ -1 +1 @@ -<%= render "relabeled_issuable_email", issuable: @issue %> +<%= render 'relabeled_issuable_email', issuable: @issue %> diff --git a/app/views/notify/relabeled_merge_request_email.html.haml b/app/views/notify/relabeled_merge_request_email.html.haml index 3937b449a37..9eaa9afa5b1 100644 --- a/app/views/notify/relabeled_merge_request_email.html.haml +++ b/app/views/notify/relabeled_merge_request_email.html.haml @@ -1 +1 @@ -= render "relabeled_issuable_email", issuable: @merge_request += render 'relabeled_issuable_email', issuable: @merge_request diff --git a/app/views/notify/relabeled_merge_request_email.text.erb b/app/views/notify/relabeled_merge_request_email.text.erb index 5c4d86e4dc2..87bc80ead32 100644 --- a/app/views/notify/relabeled_merge_request_email.text.erb +++ b/app/views/notify/relabeled_merge_request_email.text.erb @@ -1 +1 @@ -<%= render "relabeled_issuable_email", issuable: @merge_request %> +<%= render 'relabeled_issuable_email', issuable: @merge_request %> diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index 3b14a925c46..4927d239c1e 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -11,17 +11,15 @@ = pluralize label.open_issues_count, 'open issue' - if current_user - %div{class: "label-subscription", data: {id: label.id}} - - subscribed = label.subscribed?(current_user) - - subscription_status = subscribed ? 'subscribed' : 'unsubscribed' - .subscription-status{data: {status: subscription_status}} - %button.btn.btn-sm.btn-info.subscribe-button{:type => 'button'} - %span= subscribed ? 'Unsubscribe' : 'Subscribe' + .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?"} -:javascript - new Subscription("#{toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}", - ".label-subscription[data-id='#{label.id}']"); +- if current_user + :javascript + new Subscription('##{dom_id(label)} .label-subscription'); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index b44086d4205..23b1ed1e51b 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -98,7 +98,7 @@ %hr - if current_user - subscribed = issuable.subscribed?(current_user) - .block.light.subscription + .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)}", ".subscription"); + new Subscription('.subscription'); new IssuableContext(); diff --git a/features/project/labels.feature b/features/project/labels.feature index cd3f3a789f0..955bc3d8b1b 100644 --- a/features/project/labels.feature +++ b/features/project/labels.feature @@ -3,14 +3,13 @@ Feature: Labels Background: Given I sign in as a user And I own project "Shop" - And I visit project "Shop" issues page And project "Shop" has labels: "bug", "feature", "enhancement" + When I visit project "Shop" labels page @javascript Scenario: I can subscribe to a label - When I visit project "Shop" labels page - Then I should see that I am unsubscribed - When I click button "Subscribe" - Then I should see that I am subscribed - When I click button "Unsubscribe" - Then I should see that I am unsubscribed + 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 diff --git a/features/steps/project/labels.rb b/features/steps/project/labels.rb index 3f800a10594..17944527e3a 100644 --- a/features/steps/project/labels.rb +++ b/features/steps/project/labels.rb @@ -10,19 +10,19 @@ class Spinach::Features::Labels < Spinach::FeatureSteps visit namespace_project_labels_path(project.namespace, project) end - step 'I should see that I am subscribed' do + 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 unsubscribed' do + 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"' do + step 'I click button "Unsubscribe" for the "bug" label' do subscribe_button.click end - step 'I click button "Subscribe"' do + step 'I click button "Subscribe" for the "bug" label' do subscribe_button.click end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 91a7400afa1..ea2be8928d5 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -13,7 +13,7 @@ FactoryGirl.define do factory :label do - title { FFaker::Color.name } + sequence(:title) { |n| "label#{n}" } color "#990000" project end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 232a11245a6..f910424d85b 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -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) } diff --git a/spec/mailers/shared/notify.rb b/spec/mailers/shared/notify.rb index 48c851ebbd6..6019af544d3 100644 --- a/spec/mailers/shared/notify.rb +++ b/spec/mailers/shared/notify.rb @@ -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 diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index aff384c2949..be29b6d66ff 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -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 } diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index 9ee60426a5d..e31fdb0bffb 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -1,15 +1,56 @@ -require "spec_helper" +require 'spec_helper' -describe Subscribable, "Subscribable" do +describe Subscribable, 'Subscribable' do let(:resource) { create(:issue) } let(:user) { create(:user) } - describe "#subscribed?" do - it do + describe '#subscribed?' do + it 'returns false when no subcription exists' do expect(resource.subscribed?(user)).to be_falsey - resource.toggle_subscription(user) + 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 diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index dc9d8329751..4ffe753fef5 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -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 @@ -148,57 +149,45 @@ describe Issues::UpdateService, services: true do end end - context "when the issue is relabeled" do - it "sends notifications for subscribers of newly added labels" do - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + 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 - @issue.reload should_email(subscriber) should_not_email(non_subscriber) end - it "does send notifications for existing labels" do - second_label = create(:label) - issue.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + context 'when issue has the `label` label' do + before { issue.labels << label } - opts = { label_ids: [label.id, second_label.id] } + 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) + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) end - @issue.reload - should_email(subscriber) - should_not_email(non_subscriber) - end + it 'does not send notifications for removed labels' do + opts = { label_ids: [label2.id] } - it "does not send notifications for removed labels" do - second_label = create(:label) - issue.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end - opts = { label_ids: [second_label.id] } - - perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + should_not_email(subscriber) + should_not_email(non_subscriber) end - - @issue.reload - should_not_email(subscriber) - should_not_email(non_subscriber) end end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 104e63ccfee..cb8cff2fa8c 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -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] @@ -176,57 +177,45 @@ describe MergeRequests::UpdateService, services: true do end end - context "when the merge request is relabeled" do - it "sends notifications for subscribers of newly added labels" do - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + 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 - @merge_request.reload should_email(subscriber) should_not_email(non_subscriber) end - it "does send notifications for existing labels" do - second_label = create(:label) - merge_request.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + context 'when issue has the `label` label' do + before { merge_request.labels << label } - opts = { label_ids: [label.id, second_label.id] } + 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) + 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 - @merge_request.reload - should_email(subscriber) - should_not_email(non_subscriber) - end + it 'does not send notifications for removed labels' do + opts = { label_ids: [label2.id] } - it "does not send notifications for removed labels" do - second_label = create(:label) - merge_request.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end - opts = { label_ids: [second_label.id] } - - perform_enqueued_jobs do - @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + should_not_email(subscriber) + should_not_email(non_subscriber) end - - @merge_request.reload - should_not_email(subscriber) - should_not_email(non_subscriber) end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 35afa768057..b5407397c1d 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -225,14 +225,13 @@ describe NotificationService, services: true do should_not_email(issue.assignee) end - it "should email subscribers of the issue's labels" do - subscriber, non_subscriber = create_list(:user, 2) + it "emails subscribers of the issue's labels" do + subscriber = create(:user) label = create(:label, issues: [issue]) label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } notification.new_issue(issue, @u_disabled) + should_email(subscriber) - should_not_email(non_subscriber) end end @@ -306,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) @@ -336,32 +364,6 @@ describe NotificationService, services: true do should_not_email(@u_participating) end end - - describe :relabel_issue do - it "sends email to subscribers of the given labels" do - subscriber, non_subscriber = create_list(:user, 2) - label = create(:label, issues: [issue]) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } - notification.relabeled_issue(issue, [label], @u_disabled) - should_email(subscriber) - should_not_email(non_subscriber) - end - - it "doesn't send email to anyone but subscribers of the given labels" do - label = create(:label, issues: [issue]) - notification.relabeled_issue(issue, [label], @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) - end - end end describe 'Merge Requests' do @@ -386,15 +388,13 @@ describe NotificationService, services: true do should_not_email(@u_disabled) end - it "should email subscribers of the MR's labels" do - subscriber, non_subscriber = create_list(:user, 2) - label = create(:label) - merge_request.labels << label + it "emails subscribers of the merge request's labels" do + subscriber = create(:user) + label = create(:label, merge_requests: [merge_request]) label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } notification.new_merge_request(merge_request, @u_disabled) + should_email(subscriber) - should_not_email(non_subscriber) end end @@ -413,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) @@ -457,34 +486,6 @@ describe NotificationService, services: true do should_not_email(@u_disabled) end end - - describe :relabel_merge_request do - it "sends email to subscribers of the given labels" do - subscriber, non_subscriber = create_list(:user, 2) - label = create(:label) - merge_request.labels << label - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } - notification.relabeled_merge_request(merge_request, [label], @u_disabled) - should_email(subscriber) - should_not_email(non_subscriber) - end - - it "doesn't send email to anyone but subscribers of the given labels" do - label = create(:label) - merge_request.labels << label - notification.relabeled_merge_request(merge_request, [label], @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) - end - end end describe 'Projects' do From c98089b2b29bbcb44cdce7ce23bff3fa5d10d3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 17:18:47 +0100 Subject: [PATCH 58/59] Move the #toggle_subscription controller method to a concern --- app/controllers/projects/issues_controller.rb | 13 ++++--------- app/controllers/projects/labels_controller.rb | 18 ++++++++---------- .../projects/merge_requests_controller.rb | 12 +++--------- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 24a862814b3..b0a03ee45cc 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,6 +1,8 @@ class Projects::IssuesController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled - before_action :issue, only: [:edit, :update, :show, :toggle_subscription] + before_action :issue, only: [:edit, :update, :show] # Allow read any issue before_action :authorize_read_issue! @@ -110,14 +112,6 @@ class Projects::IssuesController < Projects::ApplicationController redirect_back_or_default(default: { action: 'index' }, options: { notice: "#{result[:count]} issues updated" }) end - def toggle_subscription - return unless current_user - - @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 @@ -131,6 +125,7 @@ class Projects::IssuesController < Projects::ApplicationController redirect_old end end + alias_method :subscribable_resource, :issue def authorize_update_issue! return render_404 unless can?(current_user, :update_issue, @issue) diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index e4dea6b065a..40d8098690a 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -1,8 +1,12 @@ class Projects::LabelsController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled - before_action :label, only: [:edit, :update, :destroy, :toggle_subscription] + before_action :label, only: [:edit, :update, :destroy] before_action :authorize_read_label! - before_action :authorize_admin_labels!, except: [:index, :toggle_subscription] + before_action :authorize_admin_labels!, only: [ + :new, :create, :edit, :update, :generate, :destroy + ] respond_to :js, :html @@ -60,13 +64,6 @@ class Projects::LabelsController < Projects::ApplicationController end end - def toggle_subscription - return unless current_user - - @label.toggle_subscription(current_user) - render nothing: true - end - protected def module_enabled @@ -80,8 +77,9 @@ class Projects::LabelsController < Projects::ApplicationController end def label - @label = @project.labels.find(params[:id]) + @label ||= @project.labels.find(params[:id]) end + alias_method :subscribable_resource, :label def authorize_admin_labels! return render_404 unless can?(current_user, :admin_label, @project) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 954ee55a211..61b82c9db46 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -1,10 +1,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController + include ToggleSubscriptionAction include DiffHelper before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds + :ci_status, :cancel_merge_when_build_succeeds ] before_action :closes_issues, only: [:edit, :update, :show, :diffs, :commits, :builds] before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] @@ -233,14 +234,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end - def toggle_subscription - return unless current_user - - @merge_request.toggle_subscription(current_user) - - render nothing: true - end - protected def selected_target_project @@ -254,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 From e90d6ec1d83ff86743e70931b49647eaf3e3e67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 17:24:55 +0100 Subject: [PATCH 59/59] Create a SentNotification record for #relabeled_issue_email / #relabeled_merge_request_email --- .../concerns/toggle_subscription_action.rb | 17 +++++++++++++++++ app/mailers/emails/issues.rb | 8 +++----- app/mailers/emails/merge_requests.rb | 8 +++----- 3 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 app/controllers/concerns/toggle_subscription_action.rb diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb new file mode 100644 index 00000000000..8a43c0b93c4 --- /dev/null +++ b/app/controllers/concerns/toggle_subscription_action.rb @@ -0,0 +1,17 @@ +module ToggleSubscriptionAction + extend ActiveSupport::Concern + + def toggle_subscription + return unless current_user + + subscribable_resource.toggle_subscription(current_user) + + render nothing: true + end + + private + + def subscribable_resource + raise NotImplementedError + end +end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 160b6df0b97..5f9adb32e00 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -21,7 +21,7 @@ module Emails end def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id) - setup_issue_mail(issue_id, recipient_id, sent_notification: false) + setup_issue_mail(issue_id, recipient_id) @label_names = label_names @labels_url = namespace_project_labels_url(@project.namespace, @project) @@ -38,14 +38,12 @@ module Emails private - def setup_issue_mail(issue_id, recipient_id, sent_notification: true) + def setup_issue_mail(issue_id, recipient_id) @issue = Issue.find(issue_id) @project = @issue.project @target_url = namespace_project_issue_url(@project.namespace, @project, @issue) - if sent_notification - @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) - end + @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) end def issue_thread_options(sender_id, recipient_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 334bad4e2f8..55bb4f65270 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -14,7 +14,7 @@ module Emails 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, sent_notification: false) + setup_merge_request_mail(merge_request_id, recipient_id) @label_names = label_names @labels_url = namespace_project_labels_url(@project.namespace, @project) @@ -44,14 +44,12 @@ module Emails private - def setup_merge_request_mail(merge_request_id, recipient_id, sent_notification: true) + 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) - if sent_notification - @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) - end + @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end def merge_request_thread_options(sender_id, recipient_id)