From 3a609038748055a27c7e01cf4b55d8249709c9cc Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 13:06:44 +0530 Subject: [PATCH 001/195] Allow creating Personal Access Tokens through the website. --- .../personal_access_tokens_controller.rb | 22 ++++++++++ app/models/personal_access_token.rb | 9 ++++ app/models/user.rb | 1 + .../personal_access_tokens/create.html.haml | 2 + .../personal_access_tokens/index.html.haml | 44 +++++++++++++++++++ config/routes.rb | 1 + ...415062917_create_personal_access_tokens.rb | 11 +++++ db/schema.rb | 14 ++++++ 8 files changed, 104 insertions(+) create mode 100644 app/controllers/profiles/personal_access_tokens_controller.rb create mode 100644 app/models/personal_access_token.rb create mode 100644 app/views/profiles/personal_access_tokens/create.html.haml create mode 100644 app/views/profiles/personal_access_tokens/index.html.haml create mode 100644 db/migrate/20160415062917_create_personal_access_tokens.rb diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb new file mode 100644 index 0000000000..dbf06cb4c6 --- /dev/null +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -0,0 +1,22 @@ +class Profiles::PersonalAccessTokensController < ApplicationController + def index + @user = current_user + @personal_access_token = current_user.personal_access_tokens.new + end + + def create + @personal_access_token = current_user.personal_access_tokens.generate(personal_access_token_params) + + if @personal_access_token.save + redirect_to profile_personal_access_tokens_path, notice: "Created personal access token!" + else + render :index + end + end + + private + + def personal_access_token_params + params.require(:personal_access_token).permit(:name) + end +end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb new file mode 100644 index 0000000000..29f2275475 --- /dev/null +++ b/app/models/personal_access_token.rb @@ -0,0 +1,9 @@ +class PersonalAccessToken < ActiveRecord::Base + belongs_to :user + + def self.generate(params) + personal_access_token = self.new(params) + personal_access_token.token = Devise.friendly_token(50) + personal_access_token + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b6f405c698..7ac30d21cb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -109,6 +109,7 @@ class User < ActiveRecord::Base # Profile has_many :keys, dependent: :destroy has_many :emails, dependent: :destroy + has_many :personal_access_tokens, dependent: :destroy has_many :identities, dependent: :destroy, autosave: true # Groups diff --git a/app/views/profiles/personal_access_tokens/create.html.haml b/app/views/profiles/personal_access_tokens/create.html.haml new file mode 100644 index 0000000000..89da41a75e --- /dev/null +++ b/app/views/profiles/personal_access_tokens/create.html.haml @@ -0,0 +1,2 @@ +%h1 Profiles::PersonalAccessTokens#create +%p Find me in app/views/profiles/personal_access_tokens/create.html.haml diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml new file mode 100644 index 0000000000..05eed3c5c3 --- /dev/null +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -0,0 +1,44 @@ +- page_title "Personal Access Tokens" +- header_title page_title, profile_personal_access_tokens_path + +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + You can generate a personal access token for each application you use that needs access to GitLab. + .col-lg-9 + %h5.prepend-top-0 + Add a Personal Access Token + %p.profile-settings-content + Pick a name for the application, and we'll give you a unique token. + = form_for [:profile, @personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| + + .form-group + = f.label :name, class: 'label-light' + = f.text_field :name, class: "form-control", required: true + + .prepend-top-default + = f.submit 'Add Personal Access Token', class: "btn btn-create" + + %hr + + %h5 + Active Personal Access Tokens + + - if @user.personal_access_tokens.exists? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Token + %th Created At + %tbody + - @user.personal_access_tokens.each do |token| + %tr + %td= token.name + %td= token.token + %td= token.created_at + - else + %span You don't have any tokens yet. \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index d664434e1a..d1be826d2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -333,6 +333,7 @@ Rails.application.routes.draw do resources :keys resources :emails, only: [:index, :create, :destroy] resource :avatar, only: [:destroy] + resources :personal_access_tokens, only: [:index, :create] resource :two_factor_auth, only: [:new, :create, :destroy] do member do post :codes diff --git a/db/migrate/20160415062917_create_personal_access_tokens.rb b/db/migrate/20160415062917_create_personal_access_tokens.rb new file mode 100644 index 0000000000..42a41349a0 --- /dev/null +++ b/db/migrate/20160415062917_create_personal_access_tokens.rb @@ -0,0 +1,11 @@ +class CreatePersonalAccessTokens < ActiveRecord::Migration + def change + create_table :personal_access_tokens do |t| + t.references :user, index: true, foreign_key: true, null: false + t.string :token, index: {unique: true}, null: false + t.string :name, null: false + + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 42457d9235..05c9700397 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -704,6 +704,19 @@ ActiveRecord::Schema.define(version: 20160421130527) do add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree + create_table "personal_access_tokens", force: :cascade do |t| + t.integer "user_id", null: false + t.string "token", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "revoked", default: false + t.datetime "expires_at" + end + + add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree + add_index "personal_access_tokens", ["user_id"], name: "index_personal_access_tokens_on_user_id", using: :btree + create_table "project_group_links", force: :cascade do |t| t.integer "project_id", null: false t.integer "group_id", null: false @@ -1030,4 +1043,5 @@ ActiveRecord::Schema.define(version: 20160421130527) do add_index "web_hooks", ["created_at", "id"], name: "index_web_hooks_on_created_at_and_id", using: :btree add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree + add_foreign_key "personal_access_tokens", "users" end From e8314ccca5ff1cd9cf2b1d1aeccd699598b384a5 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 19:37:21 +0530 Subject: [PATCH 002/195] Refactor `API::Helpers` into `API::Helpers::Core` and `API::Helpers::Authentication` --- lib/api/api.rb | 4 +- lib/api/helpers.rb | 385 ------------------------------ lib/api/helpers/authentication.rb | 41 ++++ lib/api/helpers/core.rb | 351 +++++++++++++++++++++++++++ lib/ci/api/api.rb | 3 +- spec/requests/api/users_spec.rb | 2 +- 6 files changed, 398 insertions(+), 388 deletions(-) delete mode 100644 lib/api/helpers.rb create mode 100644 lib/api/helpers/authentication.rb create mode 100644 lib/api/helpers/core.rb diff --git a/lib/api/api.rb b/lib/api/api.rb index cc1004f800..537678863c 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -1,4 +1,5 @@ Dir["#{Rails.root}/lib/api/*.rb"].each {|file| require file} +Dir["#{Rails.root}/lib/api/helpers/*.rb"].each {|file| require file} module API class API < Grape::API @@ -25,7 +26,8 @@ module API format :json content_type :txt, "text/plain" - helpers Helpers + helpers Helpers::Core + helpers Helpers::Authentication mount Groups mount GroupMembers diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb deleted file mode 100644 index 5bbf721321..0000000000 --- a/lib/api/helpers.rb +++ /dev/null @@ -1,385 +0,0 @@ -module API - module Helpers - PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" - PRIVATE_TOKEN_PARAM = :private_token - SUDO_HEADER ="HTTP_SUDO" - SUDO_PARAM = :sudo - - def parse_boolean(value) - [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value) - end - - def current_user - private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s - @current_user ||= (User.find_by(authentication_token: private_token) || doorkeeper_guard) - - unless @current_user && Gitlab::UserAccess.allowed?(@current_user) - return nil - end - - identifier = sudo_identifier() - - # If the sudo is the current user do nothing - if identifier && !(@current_user.id == identifier || @current_user.username == identifier) - render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin? - @current_user = User.by_username_or_id(identifier) - not_found!("No user id or username for: #{identifier}") if @current_user.nil? - end - - @current_user - end - - def sudo_identifier() - identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER] - - # Regex for integers - if !!(identifier =~ /^[0-9]+$/) - identifier.to_i - else - identifier - end - end - - def user_project - @project ||= find_project(params[:id]) - @project || not_found!("Project") - end - - def find_project(id) - project = Project.find_with_namespace(id) || Project.find_by(id: id) - - if project && can?(current_user, :read_project, project) - project - else - nil - end - end - - def project_service - @project_service ||= begin - underscored_service = params[:service_slug].underscore - - if Service.available_services_names.include?(underscored_service) - user_project.build_missing_services - - service_method = "#{underscored_service}_service" - - send_service(service_method) - end - end - - @project_service || not_found!("Service") - end - - def send_service(service_method) - user_project.send(service_method) - end - - def service_attributes - @service_attributes ||= project_service.fields.inject([]) do |arr, hash| - arr << hash[:name].to_sym - end - end - - def find_group(id) - begin - group = Group.find(id) - rescue ActiveRecord::RecordNotFound - group = Group.find_by!(path: id) - end - - if can?(current_user, :read_group, group) - group - else - not_found!('Group') - end - end - - def paginate(relation) - relation.page(params[:page]).per(params[:per_page].to_i).tap do |data| - add_pagination_headers(data) - end - end - - def authenticate! - unauthorized! unless current_user - end - - def authenticate_by_gitlab_shell_token! - input = params['secret_token'].try(:chomp) - unless Devise.secure_compare(secret_token, input) - unauthorized! - end - end - - def authenticated_as_admin! - forbidden! unless current_user.is_admin? - end - - def authorize!(action, subject) - forbidden! unless abilities.allowed?(current_user, action, subject) - end - - def authorize_push_project - authorize! :push_code, user_project - end - - def authorize_admin_project - authorize! :admin_project, user_project - end - - def require_gitlab_workhorse! - unless env['HTTP_GITLAB_WORKHORSE'].present? - forbidden!('Request should be executed via GitLab Workhorse') - end - end - - def can?(object, action, subject) - abilities.allowed?(object, action, subject) - end - - # Checks the occurrences of required attributes, each attribute must be present in the params hash - # or a Bad Request error is invoked. - # - # Parameters: - # keys (required) - A hash consisting of keys that must be present - def required_attributes!(keys) - keys.each do |key| - bad_request!(key) unless params[key].present? - end - end - - def attributes_for_keys(keys, custom_params = nil) - params_hash = custom_params || params - attrs = {} - keys.each do |key| - if params_hash[key].present? or (params_hash.has_key?(key) and params_hash[key] == false) - attrs[key] = params_hash[key] - end - end - ActionController::Parameters.new(attrs).permit! - end - - # Helper method for validating all labels against its names - def validate_label_params(params) - errors = {} - - if params[:labels].present? - params[:labels].split(',').each do |label_name| - label = user_project.labels.create_with( - color: Label::DEFAULT_COLOR).find_or_initialize_by( - title: label_name.strip) - - if label.invalid? - errors[label.title] = label.errors - end - end - end - - errors - end - - def validate_access_level?(level) - Gitlab::Access.options_with_owner.values.include? level.to_i - end - - def issuable_order_by - if params["order_by"] == 'updated_at' - 'updated_at' - else - 'created_at' - end - end - - def issuable_sort - if params["sort"] == 'asc' - :asc - else - :desc - end - end - - def filter_by_iid(items, iid) - items.where(iid: iid) - end - - # error helpers - - def forbidden!(reason = nil) - message = ['403 Forbidden'] - message << " - #{reason}" if reason - render_api_error!(message.join(' '), 403) - end - - def bad_request!(attribute) - message = ["400 (Bad request)"] - message << "\"" + attribute.to_s + "\" not given" - render_api_error!(message.join(' '), 400) - end - - def not_found!(resource = nil) - message = ["404"] - message << resource if resource - message << "Not Found" - render_api_error!(message.join(' '), 404) - end - - def unauthorized! - render_api_error!('401 Unauthorized', 401) - end - - def not_allowed! - render_api_error!('405 Method Not Allowed', 405) - end - - def conflict!(message = nil) - render_api_error!(message || '409 Conflict', 409) - end - - def file_to_large! - render_api_error!('413 Request Entity Too Large', 413) - end - - def not_modified! - render_api_error!('304 Not Modified', 304) - end - - def render_validation_error!(model) - if model.errors.any? - render_api_error!(model.errors.messages || '400 Bad Request', 400) - end - end - - def render_api_error!(message, status) - error!({ 'message' => message }, status) - end - - # Projects helpers - - def filter_projects(projects) - # If the archived parameter is passed, limit results accordingly - if params[:archived].present? - projects = projects.where(archived: parse_boolean(params[:archived])) - end - - if params[:search].present? - projects = projects.search(params[:search]) - end - - if params[:visibility].present? - projects = projects.search_by_visibility(params[:visibility]) - end - - projects.reorder(project_order_by => project_sort) - end - - def project_order_by - order_fields = %w(id name path created_at updated_at last_activity_at) - - if order_fields.include?(params['order_by']) - params['order_by'] - else - 'created_at' - end - end - - def project_sort - if params["sort"] == 'asc' - :asc - else - :desc - end - end - - # file helpers - - def uploaded_file(field, uploads_path) - if params[field] - bad_request!("#{field} is not a file") unless params[field].respond_to?(:filename) - return params[field] - end - - return nil unless params["#{field}.path"] && params["#{field}.name"] - - # sanitize file paths - # this requires all paths to exist - required_attributes! %W(#{field}.path) - uploads_path = File.realpath(uploads_path) - file_path = File.realpath(params["#{field}.path"]) - bad_request!('Bad file path') unless file_path.start_with?(uploads_path) - - UploadedFile.new( - file_path, - params["#{field}.name"], - params["#{field}.type"] || 'application/octet-stream', - ) - end - - def present_file!(path, filename, content_type = 'application/octet-stream') - filename ||= File.basename(path) - header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Content-Transfer-Encoding'] = 'binary' - content_type content_type - - # Support download acceleration - case headers['X-Sendfile-Type'] - when 'X-Sendfile' - header['X-Sendfile'] = path - body - else - file FileStreamer.new(path) - end - end - - private - - def add_pagination_headers(paginated_data) - header 'X-Total', paginated_data.total_count.to_s - header 'X-Total-Pages', paginated_data.total_pages.to_s - header 'X-Per-Page', paginated_data.limit_value.to_s - header 'X-Page', paginated_data.current_page.to_s - header 'X-Next-Page', paginated_data.next_page.to_s - header 'X-Prev-Page', paginated_data.prev_page.to_s - header 'Link', pagination_links(paginated_data) - end - - def pagination_links(paginated_data) - request_url = request.url.split('?').first - request_params = params.clone - request_params[:per_page] = paginated_data.limit_value - - links = [] - - request_params[:page] = paginated_data.current_page - 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page? - - request_params[:page] = paginated_data.current_page + 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page? - - request_params[:page] = 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="first") - - request_params[:page] = paginated_data.total_pages - links << %(<#{request_url}?#{request_params.to_query}>; rel="last") - - links.join(', ') - end - - def abilities - @abilities ||= begin - abilities = Six.new - abilities << Ability - abilities - end - end - - def secret_token - File.read(Gitlab.config.gitlab_shell.secret_file).chomp - end - - def handle_member_errors(errors) - error!(errors[:access_level], 422) if errors[:access_level].any? - not_found!(errors) - end - end -end diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb new file mode 100644 index 0000000000..8c7bb2c8cd --- /dev/null +++ b/lib/api/helpers/authentication.rb @@ -0,0 +1,41 @@ +module API + module Helpers + module Authentication + PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" + PRIVATE_TOKEN_PARAM = :private_token + SUDO_HEADER ="HTTP_SUDO" + SUDO_PARAM = :sudo + + def current_user + private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + @current_user ||= (User.find_by(authentication_token: private_token) || doorkeeper_guard) + + unless @current_user && Gitlab::UserAccess.allowed?(@current_user) + return nil + end + + identifier = sudo_identifier() + + # If the sudo is the current user do nothing + if identifier && !(@current_user.id == identifier || @current_user.username == identifier) + render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin? + @current_user = User.by_username_or_id(identifier) + not_found!("No user id or username for: #{identifier}") if @current_user.nil? + end + + @current_user + end + + def sudo_identifier() + identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER] + + # Regex for integers + if !!(identifier =~ /^[0-9]+$/) + identifier.to_i + else + identifier + end + end + end + end +end \ No newline at end of file diff --git a/lib/api/helpers/core.rb b/lib/api/helpers/core.rb new file mode 100644 index 0000000000..c37064d49a --- /dev/null +++ b/lib/api/helpers/core.rb @@ -0,0 +1,351 @@ +module API + module Helpers + module Core + def parse_boolean(value) + [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value) + end + + def user_project + @project ||= find_project(params[:id]) + @project || not_found!("Project") + end + + def find_project(id) + project = Project.find_with_namespace(id) || Project.find_by(id: id) + + if project && can?(current_user, :read_project, project) + project + else + nil + end + end + + def project_service + @project_service ||= begin + underscored_service = params[:service_slug].underscore + + if Service.available_services_names.include?(underscored_service) + user_project.build_missing_services + + service_method = "#{underscored_service}_service" + + send_service(service_method) + end + end + + @project_service || not_found!("Service") + end + + def send_service(service_method) + user_project.send(service_method) + end + + def service_attributes + @service_attributes ||= project_service.fields.inject([]) do |arr, hash| + arr << hash[:name].to_sym + end + end + + def find_group(id) + begin + group = Group.find(id) + rescue ActiveRecord::RecordNotFound + group = Group.find_by!(path: id) + end + + if can?(current_user, :read_group, group) + group + else + not_found!('Group') + end + end + + def paginate(relation) + relation.page(params[:page]).per(params[:per_page].to_i).tap do |data| + add_pagination_headers(data) + end + end + + def authenticate! + unauthorized! unless current_user + end + + def authenticate_by_gitlab_shell_token! + input = params['secret_token'].try(:chomp) + unless Devise.secure_compare(secret_token, input) + unauthorized! + end + end + + def authenticated_as_admin! + forbidden! unless current_user.is_admin? + end + + def authorize!(action, subject) + forbidden! unless abilities.allowed?(current_user, action, subject) + end + + def authorize_push_project + authorize! :push_code, user_project + end + + def authorize_admin_project + authorize! :admin_project, user_project + end + + def require_gitlab_workhorse! + unless env['HTTP_GITLAB_WORKHORSE'].present? + forbidden!('Request should be executed via GitLab Workhorse') + end + end + + def can?(object, action, subject) + abilities.allowed?(object, action, subject) + end + + # Checks the occurrences of required attributes, each attribute must be present in the params hash + # or a Bad Request error is invoked. + # + # Parameters: + # keys (required) - A hash consisting of keys that must be present + def required_attributes!(keys) + keys.each do |key| + bad_request!(key) unless params[key].present? + end + end + + def attributes_for_keys(keys, custom_params = nil) + params_hash = custom_params || params + attrs = {} + keys.each do |key| + if params_hash[key].present? or (params_hash.has_key?(key) and params_hash[key] == false) + attrs[key] = params_hash[key] + end + end + ActionController::Parameters.new(attrs).permit! + end + + # Helper method for validating all labels against its names + def validate_label_params(params) + errors = {} + + if params[:labels].present? + params[:labels].split(',').each do |label_name| + label = user_project.labels.create_with( + color: Label::DEFAULT_COLOR).find_or_initialize_by( + title: label_name.strip) + + if label.invalid? + errors[label.title] = label.errors + end + end + end + + errors + end + + def validate_access_level?(level) + Gitlab::Access.options_with_owner.values.include? level.to_i + end + + def issuable_order_by + if params["order_by"] == 'updated_at' + 'updated_at' + else + 'created_at' + end + end + + def issuable_sort + if params["sort"] == 'asc' + :asc + else + :desc + end + end + + def filter_by_iid(items, iid) + items.where(iid: iid) + end + + # error helpers + + def forbidden!(reason = nil) + message = ['403 Forbidden'] + message << " - #{reason}" if reason + render_api_error!(message.join(' '), 403) + end + + def bad_request!(attribute) + message = ["400 (Bad request)"] + message << "\"" + attribute.to_s + "\" not given" + render_api_error!(message.join(' '), 400) + end + + def not_found!(resource = nil) + message = ["404"] + message << resource if resource + message << "Not Found" + render_api_error!(message.join(' '), 404) + end + + def unauthorized! + render_api_error!('401 Unauthorized', 401) + end + + def not_allowed! + render_api_error!('405 Method Not Allowed', 405) + end + + def conflict!(message = nil) + render_api_error!(message || '409 Conflict', 409) + end + + def file_to_large! + render_api_error!('413 Request Entity Too Large', 413) + end + + def not_modified! + render_api_error!('304 Not Modified', 304) + end + + def render_validation_error!(model) + if model.errors.any? + render_api_error!(model.errors.messages || '400 Bad Request', 400) + end + end + + def render_api_error!(message, status) + error!({ 'message' => message }, status) + end + + # Projects helpers + + def filter_projects(projects) + # If the archived parameter is passed, limit results accordingly + if params[:archived].present? + projects = projects.where(archived: parse_boolean(params[:archived])) + end + + if params[:search].present? + projects = projects.search(params[:search]) + end + + if params[:visibility].present? + projects = projects.search_by_visibility(params[:visibility]) + end + + projects.reorder(project_order_by => project_sort) + end + + def project_order_by + order_fields = %w(id name path created_at updated_at last_activity_at) + + if order_fields.include?(params['order_by']) + params['order_by'] + else + 'created_at' + end + end + + def project_sort + if params["sort"] == 'asc' + :asc + else + :desc + end + end + + # file helpers + + def uploaded_file(field, uploads_path) + if params[field] + bad_request!("#{field} is not a file") unless params[field].respond_to?(:filename) + return params[field] + end + + return nil unless params["#{field}.path"] && params["#{field}.name"] + + # sanitize file paths + # this requires all paths to exist + required_attributes! %W(#{field}.path) + uploads_path = File.realpath(uploads_path) + file_path = File.realpath(params["#{field}.path"]) + bad_request!('Bad file path') unless file_path.start_with?(uploads_path) + + UploadedFile.new( + file_path, + params["#{field}.name"], + params["#{field}.type"] || 'application/octet-stream', + ) + end + + def present_file!(path, filename, content_type = 'application/octet-stream') + filename ||= File.basename(path) + header['Content-Disposition'] = "attachment; filename=#{filename}" + header['Content-Transfer-Encoding'] = 'binary' + content_type content_type + + # Support download acceleration + case headers['X-Sendfile-Type'] + when 'X-Sendfile' + header['X-Sendfile'] = path + body + else + file FileStreamer.new(path) + end + end + + private + + def add_pagination_headers(paginated_data) + header 'X-Total', paginated_data.total_count.to_s + header 'X-Total-Pages', paginated_data.total_pages.to_s + header 'X-Per-Page', paginated_data.limit_value.to_s + header 'X-Page', paginated_data.current_page.to_s + header 'X-Next-Page', paginated_data.next_page.to_s + header 'X-Prev-Page', paginated_data.prev_page.to_s + header 'Link', pagination_links(paginated_data) + end + + def pagination_links(paginated_data) + request_url = request.url.split('?').first + request_params = params.clone + request_params[:per_page] = paginated_data.limit_value + + links = [] + + request_params[:page] = paginated_data.current_page - 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page? + + request_params[:page] = paginated_data.current_page + 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page? + + request_params[:page] = 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="first") + + request_params[:page] = paginated_data.total_pages + links << %(<#{request_url}?#{request_params.to_query}>; rel="last") + + links.join(', ') + end + + def abilities + @abilities ||= begin + abilities = Six.new + abilities << Ability + abilities + end + end + + def secret_token + File.read(Gitlab.config.gitlab_shell.secret_file).chomp + end + + def handle_member_errors(errors) + error!(errors[:access_level], 422) if errors[:access_level].any? + not_found!(errors) + end + end + end +end \ No newline at end of file diff --git a/lib/ci/api/api.rb b/lib/ci/api/api.rb index 353c4ddebf..495f5792b0 100644 --- a/lib/ci/api/api.rb +++ b/lib/ci/api/api.rb @@ -28,7 +28,8 @@ module Ci format :json helpers ::Ci::API::Helpers - helpers ::API::Helpers + helpers ::API::Helpers::Core + helpers ::API::Helpers::Authentication helpers Gitlab::CurrentSettings mount Builds diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 40b24c125b..628569d3e0 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -24,7 +24,7 @@ describe API::API, api: true do context "when public level is restricted" do before do stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) - allow_any_instance_of(API::Helpers).to receive(:authenticate!).and_return(true) + allow_any_instance_of(API::Helpers::Authentication).to receive(:authenticate!).and_return(true) end it "renders 403" do From 5fb44192964c962000f8c8708d823931ee6a6d8e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 19:48:47 +0530 Subject: [PATCH 003/195] Allow personal access tokens to be used for API authentication. --- lib/api/helpers/authentication.rb | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb index 8c7bb2c8cd..f11c9725f3 100644 --- a/lib/api/helpers/authentication.rb +++ b/lib/api/helpers/authentication.rb @@ -5,10 +5,22 @@ module API PRIVATE_TOKEN_PARAM = :private_token SUDO_HEADER ="HTTP_SUDO" SUDO_PARAM = :sudo + PERSONAL_ACCESS_TOKEN_PARAM = :personal_access_token + + def find_user_by_private_token + private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + User.find_by_authentication_token(private_token) + end + + def find_user_by_personal_access_token + personal_access_token = PersonalAccessToken.find_by_token(params[PERSONAL_ACCESS_TOKEN_PARAM]) + if personal_access_token + personal_access_token.user + end + end def current_user - private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s - @current_user ||= (User.find_by(authentication_token: private_token) || doorkeeper_guard) + @current_user ||= (find_user_by_private_token || find_user_by_personal_access_token || doorkeeper_guard) unless @current_user && Gitlab::UserAccess.allowed?(@current_user) return nil From e2a4051cc3f4192849d7571bf83b0d9a7b2cbd4e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 19:55:38 +0530 Subject: [PATCH 004/195] Allow personal access tokens to be specified in a header. - In addition to a param. --- lib/api/helpers/authentication.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb index f11c9725f3..e1d7ac83ff 100644 --- a/lib/api/helpers/authentication.rb +++ b/lib/api/helpers/authentication.rb @@ -6,6 +6,7 @@ module API SUDO_HEADER ="HTTP_SUDO" SUDO_PARAM = :sudo PERSONAL_ACCESS_TOKEN_PARAM = :personal_access_token + PERSONAL_ACCESS_TOKEN_HEADER = "HTTP_PERSONAL_ACCESS_TOKEN" def find_user_by_private_token private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s @@ -13,10 +14,9 @@ module API end def find_user_by_personal_access_token - personal_access_token = PersonalAccessToken.find_by_token(params[PERSONAL_ACCESS_TOKEN_PARAM]) - if personal_access_token - personal_access_token.user - end + personal_access_token_string = (params[PERSONAL_ACCESS_TOKEN_PARAM] || env[PERSONAL_ACCESS_TOKEN_HEADER]).to_s + personal_access_token = PersonalAccessToken.find_by_token(personal_access_token_string) + personal_access_token.user if personal_access_token end def current_user From 6d76f14f54eb1af0e5c29eff1b8f5e70d2264ffd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 20:54:20 +0530 Subject: [PATCH 005/195] Allow revoking personal access tokens. --- app/assets/stylesheets/pages/profile.scss | 3 +++ .../personal_access_tokens_controller.rb | 16 +++++++++++++++- app/models/personal_access_token.rb | 7 +++++++ .../personal_access_tokens/index.html.haml | 9 ++++++++- config/locales/en.yml | 4 ++++ config/routes.rb | 6 +++++- ...d_column_revoked_to_personal_access_tokens.rb | 5 +++++ lib/api/helpers/authentication.rb | 2 +- 8 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 01f9847962..5a4d0a5c8b 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -205,3 +205,6 @@ text-align: center; } } +.personal-access-tokens-revoked-label { + color: #bbb; +} \ No newline at end of file diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index dbf06cb4c6..59de0b26ee 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -1,7 +1,11 @@ class Profiles::PersonalAccessTokensController < ApplicationController def index @user = current_user - @personal_access_token = current_user.personal_access_tokens.new + + # Prefer this to `@user.personal_access_tokens.new`, because it + # litters the view's call to `@user.personal_access_tokens` with + # this stub personal access token. + @personal_access_token = PersonalAccessToken.new(user: @user) end def create @@ -14,6 +18,16 @@ class Profiles::PersonalAccessTokensController < ApplicationController end end + def revoke + @personal_access_token = current_user.personal_access_tokens.find(params[:id]) + + if @personal_access_token.revoke! + redirect_to profile_personal_access_tokens_path, notice: "Revoked personal access token #{@personal_access_token.name}!" + else + render :index + end + end + private def personal_access_token_params diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 29f2275475..e5f1f9749f 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,9 +1,16 @@ class PersonalAccessToken < ActiveRecord::Base belongs_to :user + scope :active, -> { where.not(revoked: true) } + def self.generate(params) personal_access_token = self.new(params) personal_access_token.token = Devise.friendly_token(50) personal_access_token end + + def revoke! + self.revoked = true + self.save + end end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 05eed3c5c3..02d15269c8 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -34,11 +34,18 @@ %th Name %th Token %th Created At + %th Actions %tbody - - @user.personal_access_tokens.each do |token| + - @user.personal_access_tokens.order(:revoked).each do |token| %tr %td= token.name %td= token.token %td= token.created_at + - if token.revoked? + %td + %span.personal-access-tokens-revoked-label Revoked + - else + %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: {confirm: t('profile.personal_access_tokens.revoke.confirmation')} + - else %span You don't have any tokens yet. \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index cedb5e207b..b5b8c4467b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,3 +12,7 @@ en: pagination: previous: "Prev" next: "Next" + profile: + personal_access_tokens: + revoke: + confirmation: "Are you sure? This cannot be undone." diff --git a/config/routes.rb b/config/routes.rb index d1be826d2a..4e4666762f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -333,7 +333,11 @@ Rails.application.routes.draw do resources :keys resources :emails, only: [:index, :create, :destroy] resource :avatar, only: [:destroy] - resources :personal_access_tokens, only: [:index, :create] + resources :personal_access_tokens, only: [:index, :create] do + member do + put :revoke + end + end resource :two_factor_auth, only: [:new, :create, :destroy] do member do post :codes diff --git a/db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb b/db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb new file mode 100644 index 0000000000..8eecdfc5a1 --- /dev/null +++ b/db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb @@ -0,0 +1,5 @@ +class AddColumnRevokedToPersonalAccessTokens < ActiveRecord::Migration + def change + add_column :personal_access_tokens, :revoked, :boolean, default: false + end +end diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb index e1d7ac83ff..666bf3ffa1 100644 --- a/lib/api/helpers/authentication.rb +++ b/lib/api/helpers/authentication.rb @@ -15,7 +15,7 @@ module API def find_user_by_personal_access_token personal_access_token_string = (params[PERSONAL_ACCESS_TOKEN_PARAM] || env[PERSONAL_ACCESS_TOKEN_HEADER]).to_s - personal_access_token = PersonalAccessToken.find_by_token(personal_access_token_string) + personal_access_token = PersonalAccessToken.active.find_by_token(personal_access_token_string) personal_access_token.user if personal_access_token end From 1541d1de18c3e7707ce1289f882b4c1262ec8c71 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 18 Apr 2016 14:47:38 +0530 Subject: [PATCH 006/195] Rename `api_helpers_spec` to `api_authentication_spec` - And fix all tests. --- ...ers_spec.rb => api_authentication_spec.rb} | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) rename spec/requests/api/{api_helpers_spec.rb => api_authentication_spec.rb} (82%) diff --git a/spec/requests/api/api_helpers_spec.rb b/spec/requests/api/api_authentication_spec.rb similarity index 82% rename from spec/requests/api/api_helpers_spec.rb rename to spec/requests/api/api_authentication_spec.rb index 0c19094ec5..8bed3bb119 100644 --- a/spec/requests/api/api_helpers_spec.rb +++ b/spec/requests/api/api_authentication_spec.rb @@ -1,8 +1,10 @@ require 'spec_helper' -describe API, api: true do - include API::Helpers +describe API::Helpers::Authentication, api: true do + + include API::Helpers::Authentication include ApiHelpers + let(:user) { create(:user) } let(:admin) { create(:admin) } let(:key) { create(:key, user: user) } @@ -13,25 +15,25 @@ describe API, api: true do def set_env(token_usr, identifier) clear_env clear_param - env[API::Helpers::PRIVATE_TOKEN_HEADER] = token_usr.private_token - env[API::Helpers::SUDO_HEADER] = identifier + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = token_usr.private_token + env[API::Helpers::Authentication::SUDO_HEADER] = identifier end def set_param(token_usr, identifier) clear_env clear_param - params[API::Helpers::PRIVATE_TOKEN_PARAM] = token_usr.private_token - params[API::Helpers::SUDO_PARAM] = identifier + params[API::Helpers::Authentication::PRIVATE_TOKEN_PARAM] = token_usr.private_token + params[API::Helpers::Authentication::SUDO_PARAM] = identifier end def clear_env - env.delete(API::Helpers::PRIVATE_TOKEN_HEADER) - env.delete(API::Helpers::SUDO_HEADER) + env.delete(API::Helpers::Authentication::PRIVATE_TOKEN_HEADER) + env.delete(API::Helpers::Authentication::SUDO_HEADER) end def clear_param - params.delete(API::Helpers::PRIVATE_TOKEN_PARAM) - params.delete(API::Helpers::SUDO_PARAM) + params.delete(API::Helpers::Authentication::PRIVATE_TOKEN_PARAM) + params.delete(API::Helpers::Authentication::SUDO_PARAM) end def error!(message, status) @@ -40,22 +42,22 @@ describe API, api: true do describe ".current_user" do it "should return nil for an invalid token" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it "should return nil for a user without access" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end it "should leave user as is when sudo not specified" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token expect(current_user).to eq(user) clear_env - params[API::Helpers::PRIVATE_TOKEN_PARAM] = user.private_token + params[API::Helpers::Authentication::PRIVATE_TOKEN_PARAM] = user.private_token expect(current_user).to eq(user) end From e5cf527f279964a8952de544526e8def226b98d7 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 18 Apr 2016 15:48:54 +0530 Subject: [PATCH 007/195] Allow expiration of personal access tokens. --- app/assets/stylesheets/pages/profile.scss | 4 ++ .../personal_access_tokens_controller.rb | 2 +- app/models/personal_access_token.rb | 2 +- .../personal_access_tokens/index.html.haml | 20 +++++- ...mn_expires_at_to_personal_access_tokens.rb | 5 ++ spec/factories/personal_access_tokens.rb | 9 +++ spec/requests/api/api_authentication_spec.rb | 70 +++++++++++++++---- 7 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb create mode 100644 spec/factories/personal_access_tokens.rb diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 5a4d0a5c8b..8c62c97215 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -207,4 +207,8 @@ } .personal-access-tokens-revoked-label { color: #bbb; +} + +.personal-access-tokens-never-expires-label { + color: #bbb; } \ No newline at end of file diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 59de0b26ee..d01afbfe11 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -31,6 +31,6 @@ class Profiles::PersonalAccessTokensController < ApplicationController private def personal_access_token_params - params.require(:personal_access_token).permit(:name) + params.require(:personal_access_token).permit(:name, :expires_at) end end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index e5f1f9749f..dd64374481 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,7 +1,7 @@ class PersonalAccessToken < ActiveRecord::Base belongs_to :user - scope :active, -> { where.not(revoked: true) } + scope :active, -> { where.not(revoked: true).where("expires_at >= :current", current: Time.current) } def self.generate(params) personal_access_token = self.new(params) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 02d15269c8..f3d5f07cdd 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -18,6 +18,10 @@ = f.label :name, class: 'label-light' = f.text_field :name, class: "form-control", required: true + .form-group + = f.label :expires_at, class: 'label-light' + = f.text_field :expires_at, class: "form-control datepicker", required: false + .prepend-top-default = f.submit 'Add Personal Access Token', class: "btn btn-create" @@ -34,13 +38,19 @@ %th Name %th Token %th Created At + %th Expires At %th Actions %tbody - - @user.personal_access_tokens.order(:revoked).each do |token| + - @user.personal_access_tokens.order("revoked, expires_at").each do |token| %tr %td= token.name %td= token.token %td= token.created_at + - if token.expires_at.present? + %td= token.expires_at.to_date + - else + %td + %span.personal-access-tokens-never-expires-label Never - if token.revoked? %td %span.personal-access-tokens-revoked-label Revoked @@ -48,4 +58,10 @@ %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: {confirm: t('profile.personal_access_tokens.revoke.confirmation')} - else - %span You don't have any tokens yet. \ No newline at end of file + %span You don't have any tokens yet. + +:javascript + $(".datepicker").datepicker({ + dateFormat: "yy-mm-dd", + onSelect: function(dateText, inst) { $("#personal_access_token_expires_at").val(dateText) } + }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#personal_access_token_expires_at').val())); \ No newline at end of file diff --git a/db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb b/db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb new file mode 100644 index 0000000000..9d99ebeadf --- /dev/null +++ b/db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb @@ -0,0 +1,5 @@ +class AddColumnExpiresAtToPersonalAccessTokens < ActiveRecord::Migration + def change + add_column :personal_access_tokens, :expires_at, :datetime + end +end diff --git a/spec/factories/personal_access_tokens.rb b/spec/factories/personal_access_tokens.rb new file mode 100644 index 0000000000..da4c72bcb5 --- /dev/null +++ b/spec/factories/personal_access_tokens.rb @@ -0,0 +1,9 @@ +FactoryGirl.define do + factory :personal_access_token do + user + token { SecureRandom.hex(50) } + name { FFaker::Product.brand } + revoked false + expires_at { 5.days.from_now } + end +end diff --git a/spec/requests/api/api_authentication_spec.rb b/spec/requests/api/api_authentication_spec.rb index 8bed3bb119..0416e57c68 100644 --- a/spec/requests/api/api_authentication_spec.rb +++ b/spec/requests/api/api_authentication_spec.rb @@ -41,24 +41,64 @@ describe API::Helpers::Authentication, api: true do end describe ".current_user" do - it "should return nil for an invalid token" do - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = 'invalid token' - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } - expect(current_user).to be_nil + describe "when authenticating using a user's private token" do + it "should return nil for an invalid token" do + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = 'invalid token' + allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + expect(current_user).to be_nil + end + + it "should return nil for a user without access" do + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token + allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) + expect(current_user).to be_nil + end + + it "should leave user as is when sudo not specified" do + env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token + expect(current_user).to eq(user) + clear_env + params[API::Helpers::Authentication::PRIVATE_TOKEN_PARAM] = user.private_token + expect(current_user).to eq(user) + end end - it "should return nil for a user without access" do - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token - allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) - expect(current_user).to be_nil - end + describe "when authenticating using a user's personal access tokens" do + let(:personal_access_token) { create(:personal_access_token, user: user) } - it "should leave user as is when sudo not specified" do - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token - expect(current_user).to eq(user) - clear_env - params[API::Helpers::Authentication::PRIVATE_TOKEN_PARAM] = user.private_token - expect(current_user).to eq(user) + it "should return nil for an invalid token" do + env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = 'invalid token' + allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + expect(current_user).to be_nil + end + + it "should return nil for a user without access" do + env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) + expect(current_user).to be_nil + end + + it "should leave user as is when sudo not specified" do + env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + expect(current_user).to eq(user) + clear_env + params[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_PARAM] = personal_access_token.token + expect(current_user).to eq(user) + end + + it 'does not allow revoked tokens' do + personal_access_token.revoke! + env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + expect(current_user).to be_nil + end + + it 'does not allow expired tokens' do + personal_access_token.update_attributes!(expires_at: 1.day.ago) + env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + expect(current_user).to be_nil + end end it "should change current user to sudo when admin" do From 41b4e119e9b076cc0f36bd31cbb42f87e5ecb08f Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 14:01:21 +0530 Subject: [PATCH 008/195] Add an entry for Personal Access Tokens in the sidebar. --- .../profiles/personal_access_tokens_controller.rb | 2 +- app/views/layouts/nav/_profile.html.haml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index d01afbfe11..a5804cc0d7 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -1,4 +1,4 @@ -class Profiles::PersonalAccessTokensController < ApplicationController +class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def index @user = current_user diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index d730840d63..297f3e4063 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -14,6 +14,11 @@ = icon('cloud fw') %span Applications + = nav_link(controller: :personal_access_tokens) do + = link_to profile_personal_access_tokens_path, title: 'Personal Access Tokens' do + = icon('ticket fw') + %span + Personal Access Tokens = nav_link(controller: :emails) do = link_to profile_emails_path, title: 'Emails' do = icon('envelope-o fw') From fb2da6795c8503db5fed0f856760289e87ea9419 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 15:28:35 +0530 Subject: [PATCH 009/195] Add an "Inactive Personal Access Tokens" section. - Show the count for each section in parens - Remove the `revoked?` check, because everything in the active section is guaranteed to not be revoked. --- app/assets/stylesheets/pages/profile.scss | 4 ++ .../personal_access_tokens_controller.rb | 3 +- app/models/personal_access_token.rb | 3 +- .../personal_access_tokens/index.html.haml | 56 ++++++++++++++----- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 8c62c97215..85d9173afb 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -211,4 +211,8 @@ .personal-access-tokens-never-expires-label { color: #bbb; +} + +.personal-access-tokens-token-column { + max-width: 500px } \ No newline at end of file diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index a5804cc0d7..5a6026f58c 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -1,6 +1,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def index - @user = current_user + @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) + @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive # Prefer this to `@user.personal_access_tokens.new`, because it # litters the view's call to `@user.personal_access_tokens` with diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index dd64374481..fff3f76fb9 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,7 +1,8 @@ class PersonalAccessToken < ActiveRecord::Base belongs_to :user - scope :active, -> { where.not(revoked: true).where("expires_at >= :current", current: Time.current) } + scope :active, -> { where(revoked: false).where("expires_at >= :current OR expires_at IS NULL", current: Time.current) } + scope :inactive, -> { where("revoked = true OR expires_at < :current", current: Time.current) } def self.generate(params) personal_access_token = self.new(params) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index f3d5f07cdd..77726d34fb 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -27,38 +27,64 @@ %hr - %h5 - Active Personal Access Tokens + %h5= "Active Personal Access Tokens (#{@active_personal_access_tokens.count})" - - if @user.personal_access_tokens.exists? + - if @active_personal_access_tokens.exists? .table-responsive - %table.table.table-striped + %table.table.table-striped.table-hover %thead %tr %th Name %th Token - %th Created At - %th Expires At + %th Created + %th Expires %th Actions %tbody - - @user.personal_access_tokens.order("revoked, expires_at").each do |token| + - @active_personal_access_tokens.active.each do |token| %tr %td= token.name - %td= token.token - %td= token.created_at + %td.input-group.personal-access-tokens-token-column + %input.form-control{type: "text", value: token.token, readonly: true} + %div.input-group-btn + %button.btn.btn-default{type: "button", data: {clipboard_text: token.token}} + %i.fa.fa-clipboard + %td= token.created_at.to_date - if token.expires_at.present? %td= token.expires_at.to_date - else %td %span.personal-access-tokens-never-expires-label Never - - if token.revoked? - %td - %span.personal-access-tokens-revoked-label Revoked - - else - %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: {confirm: t('profile.personal_access_tokens.revoke.confirmation')} + %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: {confirm: t('profile.personal_access_tokens.revoke.confirmation')} - else - %span You don't have any tokens yet. + %span You don't have any active tokens yet. + + %hr + + %h5= "Inactive Personal Access Tokens (#{@inactive_personal_access_tokens.count})" + + - if @inactive_personal_access_tokens.exists? + .table-responsive + %table.table.table-striped.table-hover + %thead + %tr + %th Name + %th Token + %th Created + %tbody + - @inactive_personal_access_tokens.order("revoked, expires_at").each do |token| + %tr + %td= token.name + %td.input-group.personal-access-tokens-token-column + %input.form-control{type: "text", value: token.token, readonly: true} + %div.input-group-btn + %button.btn.btn-default{type: "button", data: {clipboard_text: token.token}} + %i.fa.fa-clipboard + %td= token.created_at.to_date + + - else + %span No inactive tokens. + :javascript $(".datepicker").datepicker({ From ade40fdcd2a4ee879bd2aba939cffaff39c65228 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 16:22:15 +0530 Subject: [PATCH 010/195] Authenticate non-API requests with personal access tokens. - Rename the `authenticate_user_from_token!` filter to `authenticate_user_from_private_token!` - Add a new `authenticate_user_from_personal_access_token!` filter - Add tests for both. --- app/controllers/application_controller.rb | 19 +++++- .../application_controller_spec.rb | 66 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1c53b0b21a..590f9383f7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,7 +7,8 @@ class ApplicationController < ActionController::Base include GitlabRoutingHelper include PageLayoutHelper - before_action :authenticate_user_from_token! + before_action :authenticate_user_from_private_token! + before_action :authenticate_user_from_personal_access_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :reject_blocked! @@ -65,7 +66,7 @@ class ApplicationController < ActionController::Base # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 - def authenticate_user_from_token! + def authenticate_user_from_private_token! user_token = if params[:authenticity_token].presence params[:authenticity_token].presence elsif params[:private_token].presence @@ -84,6 +85,20 @@ class ApplicationController < ActionController::Base end end + def authenticate_user_from_personal_access_token! + token_string = params[:personal_access_token].presence || request.headers['PERSONAL_ACCESS_TOKEN'].presence + personal_access_token = PersonalAccessToken.active.find_by_token(token_string) + user = personal_access_token && personal_access_token.user + + if user + # Notice we are passing store false, so the user is not + # actually stored in the session and a token is needed + # for every request. If you want the token to work as a + # sign in token, you can simply remove store: false. + sign_in user, store: false + end + end + def authenticate_user!(*args) if redirect_to_home_page_url? redirect_to current_application_settings.home_page_url and return diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 186239d309..1ec51465cd 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -30,4 +30,70 @@ describe ApplicationController do controller.send(:check_password_expiration) end end + + describe "#authenticate_user_from_private_token!" do + controller(ApplicationController) do + def index + render text: "authenticated" + end + end + + let(:user) { create(:user) } + + it "logs the user in when the 'authenticity_token' param is populated with the private token" do + get :index, authenticity_token: user.private_token + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end + + it "logs the user in when the 'private_token' param is populated with the private token" do + get :index, private_token: user.private_token + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end + + it "logs the user in when the 'PRIVATE-TOKEN' header is populated with the private token" do + @request.headers['PRIVATE-TOKEN'] = user.private_token + get :index + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end + + it "doesn't log the user in otherwise" do + @request.headers['PRIVATE-TOKEN'] = "token" + get :index, private_token: "token", authenticity_token: "token" + expect(response.status).to_not eq(200) + expect(response.body).to_not eq("authenticated") + end + end + + describe "#authenticate_user_from_personal_access_token!" do + controller(ApplicationController) do + def index + render text: 'authenticated' + end + end + + let(:user) { create(:user) } + let(:personal_access_token) { create(:personal_access_token, user: user) } + + it "logs the user in when the 'personal_access_token' param is populated with the personal access token" do + get :index, personal_access_token: personal_access_token.token + expect(response.status).to eq(200) + expect(response.body).to eq('authenticated') + end + + it "logs the user in when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do + @request.headers["PERSONAL_ACCESS_TOKEN"] = personal_access_token.token + get :index + expect(response.status).to eq(200) + expect(response.body).to eq('authenticated') + end + + it "doesn't log the user in otherwise" do + get :index, personal_access_token: "token" + expect(response.status).to_not eq(200) + expect(response.body).to_not eq('authenticated') + end + end end From 051324e12a7384b15aaa68f53dd43ad0b3c67812 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 16:24:33 +0530 Subject: [PATCH 011/195] Refactor `authenticate_user_from_private_token!` - No need to use `if`s when we have a `presence` check already. --- app/controllers/application_controller.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 590f9383f7..2b2726c048 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -67,13 +67,7 @@ class ApplicationController < ActionController::Base # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 def authenticate_user_from_private_token! - user_token = if params[:authenticity_token].presence - params[:authenticity_token].presence - elsif params[:private_token].presence - params[:private_token].presence - elsif request.headers['PRIVATE-TOKEN'].present? - request.headers['PRIVATE-TOKEN'] - end + user_token = params[:authenticity_token].presence || params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence user = user_token && User.find_by_authentication_token(user_token.to_s) if user From abd8eccae7d0566ed6966c5f681d4f7c07bfb016 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 16:46:03 +0530 Subject: [PATCH 012/195] Remove an unused view file. --- app/views/profiles/personal_access_tokens/create.html.haml | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 app/views/profiles/personal_access_tokens/create.html.haml diff --git a/app/views/profiles/personal_access_tokens/create.html.haml b/app/views/profiles/personal_access_tokens/create.html.haml deleted file mode 100644 index 89da41a75e..0000000000 --- a/app/views/profiles/personal_access_tokens/create.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%h1 Profiles::PersonalAccessTokens#create -%p Find me in app/views/profiles/personal_access_tokens/create.html.haml From 611f3ad2683a1103ef3c2af244a10ac9f3ae6734 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 11:14:08 +0530 Subject: [PATCH 013/195] Fix rubocop complaints. --- lib/api/helpers/authentication.rb | 2 +- lib/api/helpers/core.rb | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb index 666bf3ffa1..4109c97ed0 100644 --- a/lib/api/helpers/authentication.rb +++ b/lib/api/helpers/authentication.rb @@ -50,4 +50,4 @@ module API end end end -end \ No newline at end of file +end diff --git a/lib/api/helpers/core.rb b/lib/api/helpers/core.rb index c37064d49a..05cda8b84b 100644 --- a/lib/api/helpers/core.rb +++ b/lib/api/helpers/core.rb @@ -132,7 +132,7 @@ module API if params[:labels].present? params[:labels].split(',').each do |label_name| label = user_project.labels.create_with( - color: Label::DEFAULT_COLOR).find_or_initialize_by( + color: Label::DEFAULT_COLOR).find_or_initialize_by( title: label_name.strip) if label.invalid? @@ -274,9 +274,9 @@ module API bad_request!('Bad file path') unless file_path.start_with?(uploads_path) UploadedFile.new( - file_path, - params["#{field}.name"], - params["#{field}.type"] || 'application/octet-stream', + file_path, + params["#{field}.name"], + params["#{field}.type"] || 'application/octet-stream', ) end @@ -288,11 +288,11 @@ module API # Support download acceleration case headers['X-Sendfile-Type'] - when 'X-Sendfile' - header['X-Sendfile'] = path - body - else - file FileStreamer.new(path) + when 'X-Sendfile' + header['X-Sendfile'] = path + body + else + file FileStreamer.new(path) end end @@ -348,4 +348,4 @@ module API end end end -end \ No newline at end of file +end From 17f2fc10e6765d328b9c34a45815e183cca50466 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 11:57:45 +0530 Subject: [PATCH 014/195] Change the root param while creating personal access tokens. - Can't use `personal_access_token` anymore, because the contents of that param are assumed to be a token string, and authenticated against. --- app/controllers/profiles/personal_access_tokens_controller.rb | 4 +++- app/views/profiles/personal_access_tokens/index.html.haml | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 5a6026f58c..7fbf343edb 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -32,6 +32,8 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController private def personal_access_token_params - params.require(:personal_access_token).permit(:name, :expires_at) + # We aren't using `personal_access_token` as the root param because the authentication + # system expects to find a token string there - it's off-limits to us. + params.require(:personal_access_token_params).permit(:name, :expires_at) end end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 77726d34fb..72e67df433 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -12,7 +12,8 @@ Add a Personal Access Token %p.profile-settings-content Pick a name for the application, and we'll give you a unique token. - = form_for [:profile, @personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| + = form_for [:profile, @personal_access_token], as: "personal_access_token_params", + method: :post, html: { class: 'js-requires-input' } do |f| .form-group = f.label :name, class: 'label-light' From 25aefde62b0ac57d93ff3a6ddeef3277e40cc7bd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 11:58:48 +0530 Subject: [PATCH 015/195] Add feature specs for personal access token management. --- .../personal_access_tokens/index.html.haml | 4 +- .../profiles/personal_access_tokens_spec.rb | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 spec/features/profiles/personal_access_tokens_spec.rb diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 72e67df433..e61fa69af5 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -32,7 +32,7 @@ - if @active_personal_access_tokens.exists? .table-responsive - %table.table.table-striped.table-hover + %table.table.table-striped.table-hover.active-personal-access-tokens %thead %tr %th Name @@ -66,7 +66,7 @@ - if @inactive_personal_access_tokens.exists? .table-responsive - %table.table.table-striped.table-hover + %table.table.table-striped.table-hover.inactive-personal-access-tokens %thead %tr %th Name diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb new file mode 100644 index 0000000000..ce972776ff --- /dev/null +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe 'Profile > Personal Access Tokens', feature: true do + let(:user) { create(:user) } + + before do + login_as(user) + end + + describe "token creation" do + it "allows creation of a token with an optional expiry date" do + visit profile_personal_access_tokens_path + fill_in "Name", with: FFaker::Product.brand + expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + + active_personal_access_tokens = find(".table.active-personal-access-tokens").native.inner_html + expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) + expect(active_personal_access_tokens).to match("Never") + expect(active_personal_access_tokens).to match(PersonalAccessToken.last.token) + + fill_in "Name", with: FFaker::Product.brand + fill_in "Expires at", with: 5.days.from_now + expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + + active_personal_access_tokens = find(".table.active-personal-access-tokens").native.inner_html + expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) + expect(active_personal_access_tokens).to match(5.days.from_now.to_date.to_s) + expect(active_personal_access_tokens).to match(PersonalAccessToken.last.token) + end + end + + describe "inactive tokens" do + it "allows revocation of an active token" do + personal_access_token = create(:personal_access_token, user: user) + visit profile_personal_access_tokens_path + click_on "Revoke" + + inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native.inner_html + expect(inactive_personal_access_tokens).to match(personal_access_token.name) + expect(inactive_personal_access_tokens).to match(personal_access_token.token) + end + + it "moves expired tokens to the 'inactive' section" do + personal_access_token = create(:personal_access_token, expires_at: 5.days.ago, user: user) + visit profile_personal_access_tokens_path + + inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native.inner_html + expect(inactive_personal_access_tokens).to match(personal_access_token.name) + expect(inactive_personal_access_tokens).to match(personal_access_token.token) + end + end +end From 4e7acd88dc02e545ba696a2b4fd8c16bd6df31d5 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 12:21:14 +0530 Subject: [PATCH 016/195] Remove unnecessary javascript from the datepicker initialization. - In the personal access tokens page. - Also fix the z-index so it doesn't appear below the token text fields. --- .../personal_access_tokens/index.html.haml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index e61fa69af5..af34eb389d 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -90,5 +90,16 @@ :javascript $(".datepicker").datepicker({ dateFormat: "yy-mm-dd", - onSelect: function(dateText, inst) { $("#personal_access_token_expires_at").val(dateText) } - }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#personal_access_token_expires_at').val())); \ No newline at end of file + beforeShow: function() { + //////////////////////////////////////////////////////////////// + // 1. Need the setTimeout because the datepicker doesn't have // + // an `afterShow` callback. // + // 2. Need to set the z-index like this because we don't want // + // to target datepickers outside the current page, which // + // will happen if we set this in CSS directly. // + //////////////////////////////////////////////////////////////// + setTimeout(function(){ + $('.ui-datepicker').css('z-index', 3); + }, 0); + } + }); From c382bd266d256cdbd732be928ecb6a532bfa8ddd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 14:20:24 +0530 Subject: [PATCH 017/195] Improve performance of the personal access tokens page. --- .../profiles/personal_access_tokens/index.html.haml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index af34eb389d..02800c3791 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -28,9 +28,9 @@ %hr - %h5= "Active Personal Access Tokens (#{@active_personal_access_tokens.count})" + %h5= "Active Personal Access Tokens (#{@active_personal_access_tokens.length})" - - if @active_personal_access_tokens.exists? + - if @active_personal_access_tokens.present? .table-responsive %table.table.table-striped.table-hover.active-personal-access-tokens %thead @@ -41,7 +41,7 @@ %th Expires %th Actions %tbody - - @active_personal_access_tokens.active.each do |token| + - @active_personal_access_tokens.each do |token| %tr %td= token.name %td.input-group.personal-access-tokens-token-column @@ -62,9 +62,9 @@ %hr - %h5= "Inactive Personal Access Tokens (#{@inactive_personal_access_tokens.count})" + %h5= "Inactive Personal Access Tokens (#{@inactive_personal_access_tokens.length})" - - if @inactive_personal_access_tokens.exists? + - if @inactive_personal_access_tokens.present? .table-responsive %table.table.table-striped.table-hover.inactive-personal-access-tokens %thead @@ -73,7 +73,7 @@ %th Token %th Created %tbody - - @inactive_personal_access_tokens.order("revoked, expires_at").each do |token| + - @inactive_personal_access_tokens.each do |token| %tr %td= token.name %td.input-group.personal-access-tokens-token-column From 4076bce5d52b8a11eda48c36011666ea344ba3b7 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 22 Apr 2016 12:36:56 +0530 Subject: [PATCH 018/195] Merge the "personal access token"-related migrations. --- db/migrate/20160415062917_create_personal_access_tokens.rb | 2 ++ ...415144643_add_column_revoked_to_personal_access_tokens.rb | 5 ----- ...085954_add_column_expires_at_to_personal_access_tokens.rb | 5 ----- 3 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb delete mode 100644 db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb diff --git a/db/migrate/20160415062917_create_personal_access_tokens.rb b/db/migrate/20160415062917_create_personal_access_tokens.rb index 42a41349a0..2c9f773e30 100644 --- a/db/migrate/20160415062917_create_personal_access_tokens.rb +++ b/db/migrate/20160415062917_create_personal_access_tokens.rb @@ -4,6 +4,8 @@ class CreatePersonalAccessTokens < ActiveRecord::Migration t.references :user, index: true, foreign_key: true, null: false t.string :token, index: {unique: true}, null: false t.string :name, null: false + t.boolean :revoked, default: false + t.datetime :expires_at t.timestamps null: false end diff --git a/db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb b/db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb deleted file mode 100644 index 8eecdfc5a1..0000000000 --- a/db/migrate/20160415144643_add_column_revoked_to_personal_access_tokens.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddColumnRevokedToPersonalAccessTokens < ActiveRecord::Migration - def change - add_column :personal_access_tokens, :revoked, :boolean, default: false - end -end diff --git a/db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb b/db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb deleted file mode 100644 index 9d99ebeadf..0000000000 --- a/db/migrate/20160418085954_add_column_expires_at_to_personal_access_tokens.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddColumnExpiresAtToPersonalAccessTokens < ActiveRecord::Migration - def change - add_column :personal_access_tokens, :expires_at, :datetime - end -end From fc4bce755d19d570c4a00241048517c38aa839b3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 22 Apr 2016 14:03:11 +0530 Subject: [PATCH 019/195] Make fixes based on @vsizov's comments on MR !3749 --- app/controllers/profiles/personal_access_tokens_controller.rb | 4 ---- app/models/personal_access_token.rb | 4 ++-- lib/api/helpers/authentication.rb | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 7fbf343edb..af6def25e7 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -2,10 +2,6 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def index @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive - - # Prefer this to `@user.personal_access_tokens.new`, because it - # litters the view's call to `@user.personal_access_tokens` with - # this stub personal access token. @personal_access_token = PersonalAccessToken.new(user: @user) end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index fff3f76fb9..c7c3932ba4 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,8 +1,8 @@ class PersonalAccessToken < ActiveRecord::Base belongs_to :user - scope :active, -> { where(revoked: false).where("expires_at >= :current OR expires_at IS NULL", current: Time.current) } - scope :inactive, -> { where("revoked = true OR expires_at < :current", current: Time.current) } + scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } + scope :inactive, -> { where("revoked = true OR expires_at < NOW()") } def self.generate(params) personal_access_token = self.new(params) diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb index 4109c97ed0..4330c58027 100644 --- a/lib/api/helpers/authentication.rb +++ b/lib/api/helpers/authentication.rb @@ -42,7 +42,7 @@ module API identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER] # Regex for integers - if !!(identifier =~ /^[0-9]+$/) + if !!(identifier =~ /\A[0-9]+\z/) identifier.to_i else identifier From b22a47c62e076acddd254e2d659f38261085bf01 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 25 Apr 2016 09:00:20 +0530 Subject: [PATCH 020/195] Combine `API::Helpers::Core` and `API::Helpers::Authentication` back into `API::Helpers` - Makes the MR easier to read; this can go in a separate MR - This is a (sort of) revert of 99bea01 --- lib/api/api.rb | 4 +- lib/api/helpers.rb | 397 ++++++++++++++++++ lib/api/helpers/authentication.rb | 53 --- lib/api/helpers/core.rb | 351 ---------------- lib/ci/api/api.rb | 3 +- ...entication_spec.rb => api_helpers_spec.rb} | 40 +- spec/requests/api/users_spec.rb | 2 +- 7 files changed, 420 insertions(+), 430 deletions(-) create mode 100644 lib/api/helpers.rb delete mode 100644 lib/api/helpers/authentication.rb delete mode 100644 lib/api/helpers/core.rb rename spec/requests/api/{api_authentication_spec.rb => api_helpers_spec.rb} (79%) diff --git a/lib/api/api.rb b/lib/api/api.rb index 537678863c..cc1004f800 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -1,5 +1,4 @@ Dir["#{Rails.root}/lib/api/*.rb"].each {|file| require file} -Dir["#{Rails.root}/lib/api/helpers/*.rb"].each {|file| require file} module API class API < Grape::API @@ -26,8 +25,7 @@ module API format :json content_type :txt, "text/plain" - helpers Helpers::Core - helpers Helpers::Authentication + helpers Helpers mount Groups mount GroupMembers diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb new file mode 100644 index 0000000000..fb30ef3e25 --- /dev/null +++ b/lib/api/helpers.rb @@ -0,0 +1,397 @@ +module API + module Helpers + PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" + PRIVATE_TOKEN_PARAM = :private_token + SUDO_HEADER ="HTTP_SUDO" + SUDO_PARAM = :sudo + PERSONAL_ACCESS_TOKEN_PARAM = :personal_access_token + PERSONAL_ACCESS_TOKEN_HEADER = "HTTP_PERSONAL_ACCESS_TOKEN" + + def parse_boolean(value) + [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value) + end + + def find_user_by_private_token + private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + User.find_by_authentication_token(private_token) + end + + def find_user_by_personal_access_token + personal_access_token_string = (params[PERSONAL_ACCESS_TOKEN_PARAM] || env[PERSONAL_ACCESS_TOKEN_HEADER]).to_s + personal_access_token = PersonalAccessToken.active.find_by_token(personal_access_token_string) + personal_access_token.user if personal_access_token + end + + def current_user + @current_user ||= (find_user_by_private_token || find_user_by_personal_access_token || doorkeeper_guard) + + unless @current_user && Gitlab::UserAccess.allowed?(@current_user) + return nil + end + + identifier = sudo_identifier() + + # If the sudo is the current user do nothing + if identifier && !(@current_user.id == identifier || @current_user.username == identifier) + render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin? + @current_user = User.by_username_or_id(identifier) + not_found!("No user id or username for: #{identifier}") if @current_user.nil? + end + + @current_user + end + + def sudo_identifier() + identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER] + + # Regex for integers + if !!(identifier =~ /\A[0-9]+\z/) + identifier.to_i + else + identifier + end + end + + def user_project + @project ||= find_project(params[:id]) + @project || not_found!("Project") + end + + def find_project(id) + project = Project.find_with_namespace(id) || Project.find_by(id: id) + + if project && can?(current_user, :read_project, project) + project + else + nil + end + end + + def project_service + @project_service ||= begin + underscored_service = params[:service_slug].underscore + + if Service.available_services_names.include?(underscored_service) + user_project.build_missing_services + + service_method = "#{underscored_service}_service" + + send_service(service_method) + end + end + + @project_service || not_found!("Service") + end + + def send_service(service_method) + user_project.send(service_method) + end + + def service_attributes + @service_attributes ||= project_service.fields.inject([]) do |arr, hash| + arr << hash[:name].to_sym + end + end + + def find_group(id) + begin + group = Group.find(id) + rescue ActiveRecord::RecordNotFound + group = Group.find_by!(path: id) + end + + if can?(current_user, :read_group, group) + group + else + not_found!('Group') + end + end + + def paginate(relation) + relation.page(params[:page]).per(params[:per_page].to_i).tap do |data| + add_pagination_headers(data) + end + end + + def authenticate! + unauthorized! unless current_user + end + + def authenticate_by_gitlab_shell_token! + input = params['secret_token'].try(:chomp) + unless Devise.secure_compare(secret_token, input) + unauthorized! + end + end + + def authenticated_as_admin! + forbidden! unless current_user.is_admin? + end + + def authorize!(action, subject) + forbidden! unless abilities.allowed?(current_user, action, subject) + end + + def authorize_push_project + authorize! :push_code, user_project + end + + def authorize_admin_project + authorize! :admin_project, user_project + end + + def require_gitlab_workhorse! + unless env['HTTP_GITLAB_WORKHORSE'].present? + forbidden!('Request should be executed via GitLab Workhorse') + end + end + + def can?(object, action, subject) + abilities.allowed?(object, action, subject) + end + + # Checks the occurrences of required attributes, each attribute must be present in the params hash + # or a Bad Request error is invoked. + # + # Parameters: + # keys (required) - A hash consisting of keys that must be present + def required_attributes!(keys) + keys.each do |key| + bad_request!(key) unless params[key].present? + end + end + + def attributes_for_keys(keys, custom_params = nil) + params_hash = custom_params || params + attrs = {} + keys.each do |key| + if params_hash[key].present? or (params_hash.has_key?(key) and params_hash[key] == false) + attrs[key] = params_hash[key] + end + end + ActionController::Parameters.new(attrs).permit! + end + + # Helper method for validating all labels against its names + def validate_label_params(params) + errors = {} + + if params[:labels].present? + params[:labels].split(',').each do |label_name| + label = user_project.labels.create_with( + color: Label::DEFAULT_COLOR).find_or_initialize_by( + title: label_name.strip) + + if label.invalid? + errors[label.title] = label.errors + end + end + end + + errors + end + + def validate_access_level?(level) + Gitlab::Access.options_with_owner.values.include? level.to_i + end + + def issuable_order_by + if params["order_by"] == 'updated_at' + 'updated_at' + else + 'created_at' + end + end + + def issuable_sort + if params["sort"] == 'asc' + :asc + else + :desc + end + end + + def filter_by_iid(items, iid) + items.where(iid: iid) + end + + # error helpers + + def forbidden!(reason = nil) + message = ['403 Forbidden'] + message << " - #{reason}" if reason + render_api_error!(message.join(' '), 403) + end + + def bad_request!(attribute) + message = ["400 (Bad request)"] + message << "\"" + attribute.to_s + "\" not given" + render_api_error!(message.join(' '), 400) + end + + def not_found!(resource = nil) + message = ["404"] + message << resource if resource + message << "Not Found" + render_api_error!(message.join(' '), 404) + end + + def unauthorized! + render_api_error!('401 Unauthorized', 401) + end + + def not_allowed! + render_api_error!('405 Method Not Allowed', 405) + end + + def conflict!(message = nil) + render_api_error!(message || '409 Conflict', 409) + end + + def file_to_large! + render_api_error!('413 Request Entity Too Large', 413) + end + + def not_modified! + render_api_error!('304 Not Modified', 304) + end + + def render_validation_error!(model) + if model.errors.any? + render_api_error!(model.errors.messages || '400 Bad Request', 400) + end + end + + def render_api_error!(message, status) + error!({ 'message' => message }, status) + end + + # Projects helpers + + def filter_projects(projects) + # If the archived parameter is passed, limit results accordingly + if params[:archived].present? + projects = projects.where(archived: parse_boolean(params[:archived])) + end + + if params[:search].present? + projects = projects.search(params[:search]) + end + + if params[:visibility].present? + projects = projects.search_by_visibility(params[:visibility]) + end + + projects.reorder(project_order_by => project_sort) + end + + def project_order_by + order_fields = %w(id name path created_at updated_at last_activity_at) + + if order_fields.include?(params['order_by']) + params['order_by'] + else + 'created_at' + end + end + + def project_sort + if params["sort"] == 'asc' + :asc + else + :desc + end + end + + # file helpers + + def uploaded_file(field, uploads_path) + if params[field] + bad_request!("#{field} is not a file") unless params[field].respond_to?(:filename) + return params[field] + end + + return nil unless params["#{field}.path"] && params["#{field}.name"] + + # sanitize file paths + # this requires all paths to exist + required_attributes! %W(#{field}.path) + uploads_path = File.realpath(uploads_path) + file_path = File.realpath(params["#{field}.path"]) + bad_request!('Bad file path') unless file_path.start_with?(uploads_path) + + UploadedFile.new( + file_path, + params["#{field}.name"], + params["#{field}.type"] || 'application/octet-stream', + ) + end + + def present_file!(path, filename, content_type = 'application/octet-stream') + filename ||= File.basename(path) + header['Content-Disposition'] = "attachment; filename=#{filename}" + header['Content-Transfer-Encoding'] = 'binary' + content_type content_type + + # Support download acceleration + case headers['X-Sendfile-Type'] + when 'X-Sendfile' + header['X-Sendfile'] = path + body + else + file FileStreamer.new(path) + end + end + + private + + def add_pagination_headers(paginated_data) + header 'X-Total', paginated_data.total_count.to_s + header 'X-Total-Pages', paginated_data.total_pages.to_s + header 'X-Per-Page', paginated_data.limit_value.to_s + header 'X-Page', paginated_data.current_page.to_s + header 'X-Next-Page', paginated_data.next_page.to_s + header 'X-Prev-Page', paginated_data.prev_page.to_s + header 'Link', pagination_links(paginated_data) + end + + def pagination_links(paginated_data) + request_url = request.url.split('?').first + request_params = params.clone + request_params[:per_page] = paginated_data.limit_value + + links = [] + + request_params[:page] = paginated_data.current_page - 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page? + + request_params[:page] = paginated_data.current_page + 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page? + + request_params[:page] = 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="first") + + request_params[:page] = paginated_data.total_pages + links << %(<#{request_url}?#{request_params.to_query}>; rel="last") + + links.join(', ') + end + + def abilities + @abilities ||= begin + abilities = Six.new + abilities << Ability + abilities + end + end + + def secret_token + File.read(Gitlab.config.gitlab_shell.secret_file).chomp + end + + def handle_member_errors(errors) + error!(errors[:access_level], 422) if errors[:access_level].any? + not_found!(errors) + end + end +end diff --git a/lib/api/helpers/authentication.rb b/lib/api/helpers/authentication.rb deleted file mode 100644 index 4330c58027..0000000000 --- a/lib/api/helpers/authentication.rb +++ /dev/null @@ -1,53 +0,0 @@ -module API - module Helpers - module Authentication - PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" - PRIVATE_TOKEN_PARAM = :private_token - SUDO_HEADER ="HTTP_SUDO" - SUDO_PARAM = :sudo - PERSONAL_ACCESS_TOKEN_PARAM = :personal_access_token - PERSONAL_ACCESS_TOKEN_HEADER = "HTTP_PERSONAL_ACCESS_TOKEN" - - def find_user_by_private_token - private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s - User.find_by_authentication_token(private_token) - end - - def find_user_by_personal_access_token - personal_access_token_string = (params[PERSONAL_ACCESS_TOKEN_PARAM] || env[PERSONAL_ACCESS_TOKEN_HEADER]).to_s - personal_access_token = PersonalAccessToken.active.find_by_token(personal_access_token_string) - personal_access_token.user if personal_access_token - end - - def current_user - @current_user ||= (find_user_by_private_token || find_user_by_personal_access_token || doorkeeper_guard) - - unless @current_user && Gitlab::UserAccess.allowed?(@current_user) - return nil - end - - identifier = sudo_identifier() - - # If the sudo is the current user do nothing - if identifier && !(@current_user.id == identifier || @current_user.username == identifier) - render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin? - @current_user = User.by_username_or_id(identifier) - not_found!("No user id or username for: #{identifier}") if @current_user.nil? - end - - @current_user - end - - def sudo_identifier() - identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER] - - # Regex for integers - if !!(identifier =~ /\A[0-9]+\z/) - identifier.to_i - else - identifier - end - end - end - end -end diff --git a/lib/api/helpers/core.rb b/lib/api/helpers/core.rb deleted file mode 100644 index 05cda8b84b..0000000000 --- a/lib/api/helpers/core.rb +++ /dev/null @@ -1,351 +0,0 @@ -module API - module Helpers - module Core - def parse_boolean(value) - [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value) - end - - def user_project - @project ||= find_project(params[:id]) - @project || not_found!("Project") - end - - def find_project(id) - project = Project.find_with_namespace(id) || Project.find_by(id: id) - - if project && can?(current_user, :read_project, project) - project - else - nil - end - end - - def project_service - @project_service ||= begin - underscored_service = params[:service_slug].underscore - - if Service.available_services_names.include?(underscored_service) - user_project.build_missing_services - - service_method = "#{underscored_service}_service" - - send_service(service_method) - end - end - - @project_service || not_found!("Service") - end - - def send_service(service_method) - user_project.send(service_method) - end - - def service_attributes - @service_attributes ||= project_service.fields.inject([]) do |arr, hash| - arr << hash[:name].to_sym - end - end - - def find_group(id) - begin - group = Group.find(id) - rescue ActiveRecord::RecordNotFound - group = Group.find_by!(path: id) - end - - if can?(current_user, :read_group, group) - group - else - not_found!('Group') - end - end - - def paginate(relation) - relation.page(params[:page]).per(params[:per_page].to_i).tap do |data| - add_pagination_headers(data) - end - end - - def authenticate! - unauthorized! unless current_user - end - - def authenticate_by_gitlab_shell_token! - input = params['secret_token'].try(:chomp) - unless Devise.secure_compare(secret_token, input) - unauthorized! - end - end - - def authenticated_as_admin! - forbidden! unless current_user.is_admin? - end - - def authorize!(action, subject) - forbidden! unless abilities.allowed?(current_user, action, subject) - end - - def authorize_push_project - authorize! :push_code, user_project - end - - def authorize_admin_project - authorize! :admin_project, user_project - end - - def require_gitlab_workhorse! - unless env['HTTP_GITLAB_WORKHORSE'].present? - forbidden!('Request should be executed via GitLab Workhorse') - end - end - - def can?(object, action, subject) - abilities.allowed?(object, action, subject) - end - - # Checks the occurrences of required attributes, each attribute must be present in the params hash - # or a Bad Request error is invoked. - # - # Parameters: - # keys (required) - A hash consisting of keys that must be present - def required_attributes!(keys) - keys.each do |key| - bad_request!(key) unless params[key].present? - end - end - - def attributes_for_keys(keys, custom_params = nil) - params_hash = custom_params || params - attrs = {} - keys.each do |key| - if params_hash[key].present? or (params_hash.has_key?(key) and params_hash[key] == false) - attrs[key] = params_hash[key] - end - end - ActionController::Parameters.new(attrs).permit! - end - - # Helper method for validating all labels against its names - def validate_label_params(params) - errors = {} - - if params[:labels].present? - params[:labels].split(',').each do |label_name| - label = user_project.labels.create_with( - color: Label::DEFAULT_COLOR).find_or_initialize_by( - title: label_name.strip) - - if label.invalid? - errors[label.title] = label.errors - end - end - end - - errors - end - - def validate_access_level?(level) - Gitlab::Access.options_with_owner.values.include? level.to_i - end - - def issuable_order_by - if params["order_by"] == 'updated_at' - 'updated_at' - else - 'created_at' - end - end - - def issuable_sort - if params["sort"] == 'asc' - :asc - else - :desc - end - end - - def filter_by_iid(items, iid) - items.where(iid: iid) - end - - # error helpers - - def forbidden!(reason = nil) - message = ['403 Forbidden'] - message << " - #{reason}" if reason - render_api_error!(message.join(' '), 403) - end - - def bad_request!(attribute) - message = ["400 (Bad request)"] - message << "\"" + attribute.to_s + "\" not given" - render_api_error!(message.join(' '), 400) - end - - def not_found!(resource = nil) - message = ["404"] - message << resource if resource - message << "Not Found" - render_api_error!(message.join(' '), 404) - end - - def unauthorized! - render_api_error!('401 Unauthorized', 401) - end - - def not_allowed! - render_api_error!('405 Method Not Allowed', 405) - end - - def conflict!(message = nil) - render_api_error!(message || '409 Conflict', 409) - end - - def file_to_large! - render_api_error!('413 Request Entity Too Large', 413) - end - - def not_modified! - render_api_error!('304 Not Modified', 304) - end - - def render_validation_error!(model) - if model.errors.any? - render_api_error!(model.errors.messages || '400 Bad Request', 400) - end - end - - def render_api_error!(message, status) - error!({ 'message' => message }, status) - end - - # Projects helpers - - def filter_projects(projects) - # If the archived parameter is passed, limit results accordingly - if params[:archived].present? - projects = projects.where(archived: parse_boolean(params[:archived])) - end - - if params[:search].present? - projects = projects.search(params[:search]) - end - - if params[:visibility].present? - projects = projects.search_by_visibility(params[:visibility]) - end - - projects.reorder(project_order_by => project_sort) - end - - def project_order_by - order_fields = %w(id name path created_at updated_at last_activity_at) - - if order_fields.include?(params['order_by']) - params['order_by'] - else - 'created_at' - end - end - - def project_sort - if params["sort"] == 'asc' - :asc - else - :desc - end - end - - # file helpers - - def uploaded_file(field, uploads_path) - if params[field] - bad_request!("#{field} is not a file") unless params[field].respond_to?(:filename) - return params[field] - end - - return nil unless params["#{field}.path"] && params["#{field}.name"] - - # sanitize file paths - # this requires all paths to exist - required_attributes! %W(#{field}.path) - uploads_path = File.realpath(uploads_path) - file_path = File.realpath(params["#{field}.path"]) - bad_request!('Bad file path') unless file_path.start_with?(uploads_path) - - UploadedFile.new( - file_path, - params["#{field}.name"], - params["#{field}.type"] || 'application/octet-stream', - ) - end - - def present_file!(path, filename, content_type = 'application/octet-stream') - filename ||= File.basename(path) - header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Content-Transfer-Encoding'] = 'binary' - content_type content_type - - # Support download acceleration - case headers['X-Sendfile-Type'] - when 'X-Sendfile' - header['X-Sendfile'] = path - body - else - file FileStreamer.new(path) - end - end - - private - - def add_pagination_headers(paginated_data) - header 'X-Total', paginated_data.total_count.to_s - header 'X-Total-Pages', paginated_data.total_pages.to_s - header 'X-Per-Page', paginated_data.limit_value.to_s - header 'X-Page', paginated_data.current_page.to_s - header 'X-Next-Page', paginated_data.next_page.to_s - header 'X-Prev-Page', paginated_data.prev_page.to_s - header 'Link', pagination_links(paginated_data) - end - - def pagination_links(paginated_data) - request_url = request.url.split('?').first - request_params = params.clone - request_params[:per_page] = paginated_data.limit_value - - links = [] - - request_params[:page] = paginated_data.current_page - 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page? - - request_params[:page] = paginated_data.current_page + 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page? - - request_params[:page] = 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="first") - - request_params[:page] = paginated_data.total_pages - links << %(<#{request_url}?#{request_params.to_query}>; rel="last") - - links.join(', ') - end - - def abilities - @abilities ||= begin - abilities = Six.new - abilities << Ability - abilities - end - end - - def secret_token - File.read(Gitlab.config.gitlab_shell.secret_file).chomp - end - - def handle_member_errors(errors) - error!(errors[:access_level], 422) if errors[:access_level].any? - not_found!(errors) - end - end - end -end diff --git a/lib/ci/api/api.rb b/lib/ci/api/api.rb index 495f5792b0..353c4ddebf 100644 --- a/lib/ci/api/api.rb +++ b/lib/ci/api/api.rb @@ -28,8 +28,7 @@ module Ci format :json helpers ::Ci::API::Helpers - helpers ::API::Helpers::Core - helpers ::API::Helpers::Authentication + helpers ::API::Helpers helpers Gitlab::CurrentSettings mount Builds diff --git a/spec/requests/api/api_authentication_spec.rb b/spec/requests/api/api_helpers_spec.rb similarity index 79% rename from spec/requests/api/api_authentication_spec.rb rename to spec/requests/api/api_helpers_spec.rb index 0416e57c68..06997147b0 100644 --- a/spec/requests/api/api_authentication_spec.rb +++ b/spec/requests/api/api_helpers_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' -describe API::Helpers::Authentication, api: true do +describe API::Helpers, api: true do - include API::Helpers::Authentication + include API::Helpers include ApiHelpers let(:user) { create(:user) } @@ -15,25 +15,25 @@ describe API::Helpers::Authentication, api: true do def set_env(token_usr, identifier) clear_env clear_param - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = token_usr.private_token - env[API::Helpers::Authentication::SUDO_HEADER] = identifier + env[API::Helpers::PRIVATE_TOKEN_HEADER] = token_usr.private_token + env[API::Helpers::SUDO_HEADER] = identifier end def set_param(token_usr, identifier) clear_env clear_param - params[API::Helpers::Authentication::PRIVATE_TOKEN_PARAM] = token_usr.private_token - params[API::Helpers::Authentication::SUDO_PARAM] = identifier + params[API::Helpers::PRIVATE_TOKEN_PARAM] = token_usr.private_token + params[API::Helpers::SUDO_PARAM] = identifier end def clear_env - env.delete(API::Helpers::Authentication::PRIVATE_TOKEN_HEADER) - env.delete(API::Helpers::Authentication::SUDO_HEADER) + env.delete(API::Helpers::PRIVATE_TOKEN_HEADER) + env.delete(API::Helpers::SUDO_HEADER) end def clear_param - params.delete(API::Helpers::Authentication::PRIVATE_TOKEN_PARAM) - params.delete(API::Helpers::Authentication::SUDO_PARAM) + params.delete(API::Helpers::PRIVATE_TOKEN_PARAM) + params.delete(API::Helpers::SUDO_PARAM) end def error!(message, status) @@ -43,22 +43,22 @@ describe API::Helpers::Authentication, api: true do describe ".current_user" do describe "when authenticating using a user's private token" do it "should return nil for an invalid token" do - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = 'invalid token' + env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it "should return nil for a user without access" do - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end it "should leave user as is when sudo not specified" do - env[API::Helpers::Authentication::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token expect(current_user).to eq(user) clear_env - params[API::Helpers::Authentication::PRIVATE_TOKEN_PARAM] = user.private_token + params[API::Helpers::PRIVATE_TOKEN_PARAM] = user.private_token expect(current_user).to eq(user) end end @@ -67,35 +67,35 @@ describe API::Helpers::Authentication, api: true do let(:personal_access_token) { create(:personal_access_token, user: user) } it "should return nil for an invalid token" do - env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = 'invalid token' + env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it "should return nil for a user without access" do - env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end it "should leave user as is when sudo not specified" do - env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) clear_env - params[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_PARAM] = personal_access_token.token + params[API::Helpers::PERSONAL_ACCESS_TOKEN_PARAM] = personal_access_token.token expect(current_user).to eq(user) end it 'does not allow revoked tokens' do personal_access_token.revoke! - env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it 'does not allow expired tokens' do personal_access_token.update_attributes!(expires_at: 1.day.ago) - env[API::Helpers::Authentication::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 628569d3e0..40b24c125b 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -24,7 +24,7 @@ describe API::API, api: true do context "when public level is restricted" do before do stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) - allow_any_instance_of(API::Helpers::Authentication).to receive(:authenticate!).and_return(true) + allow_any_instance_of(API::Helpers).to receive(:authenticate!).and_return(true) end it "renders 403" do From fe5eca8b38e28ad3c77d3d71297cca110306cab9 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 25 Apr 2016 09:19:25 +0530 Subject: [PATCH 021/195] Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 91fdfe6216..b3ea047475 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.8.0 (unreleased) - Make build status canceled if any of the jobs was canceled and none failed + - Allow authentication using personal acces tokens - Remove future dates from contribution calendar graph. - Use ActionDispatch Remote IP for Akismet checking - Fix error when visiting commit builds page before build was updated From bafbf22c6ab613d25287d7119d7e30770c531fdb Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 25 Apr 2016 14:30:59 +0530 Subject: [PATCH 022/195] Address @DouweM's feedback on !3749. - Use `TokenAuthenticatable` to generate the personal access token - Remove a check for `authenticity_token` in application controller; this should've been `authentication_token`, maybe, and doesn't make any sense now. - Have the datepicker appear inline --- app/controllers/application_controller.rb | 2 +- app/models/personal_access_token.rb | 5 ++++- .../personal_access_tokens/index.html.haml | 18 ++++-------------- .../controllers/application_controller_spec.rb | 6 ------ .../profiles/personal_access_tokens_spec.rb | 18 +++++++++++------- spec/models/personal_access_token_spec.rb | 15 +++++++++++++++ 6 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 spec/models/personal_access_token_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2b2726c048..eb5ffc44c3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -67,7 +67,7 @@ class ApplicationController < ActionController::Base # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 def authenticate_user_from_private_token! - user_token = params[:authenticity_token].presence || params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence + user_token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence user = user_token && User.find_by_authentication_token(user_token.to_s) if user diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index c7c3932ba4..c4b095e0c0 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,4 +1,7 @@ class PersonalAccessToken < ActiveRecord::Base + include TokenAuthenticatable + add_authentication_token_field :token + belongs_to :user scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } @@ -6,7 +9,7 @@ class PersonalAccessToken < ActiveRecord::Base def self.generate(params) personal_access_token = self.new(params) - personal_access_token.token = Devise.friendly_token(50) + personal_access_token.ensure_token personal_access_token end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 02800c3791..f7482d2c87 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -21,7 +21,8 @@ .form-group = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "form-control datepicker", required: false + = f.hidden_field :expires_at, class: "form-control", required: false + .datepicker .prepend-top-default = f.submit 'Add Personal Access Token', class: "btn btn-create" @@ -90,16 +91,5 @@ :javascript $(".datepicker").datepicker({ dateFormat: "yy-mm-dd", - beforeShow: function() { - //////////////////////////////////////////////////////////////// - // 1. Need the setTimeout because the datepicker doesn't have // - // an `afterShow` callback. // - // 2. Need to set the z-index like this because we don't want // - // to target datepickers outside the current page, which // - // will happen if we set this in CSS directly. // - //////////////////////////////////////////////////////////////// - setTimeout(function(){ - $('.ui-datepicker').css('z-index', 3); - }, 0); - } - }); + onSelect: function(dateText, inst) { $("#personal_access_token_params_expires_at").val(dateText) } + }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#personal_access_token_params_expires_at').val())); diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 1ec51465cd..e8bdbf1afb 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -40,12 +40,6 @@ describe ApplicationController do let(:user) { create(:user) } - it "logs the user in when the 'authenticity_token' param is populated with the private token" do - get :index, authenticity_token: user.private_token - expect(response.status).to eq(200) - expect(response.body).to eq("authenticated") - end - it "logs the user in when the 'private_token' param is populated with the private token" do get :index, private_token: user.private_token expect(response.status).to eq(200) diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index ce972776ff..e9fbeefae7 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Profile > Personal Access Tokens', feature: true do +describe 'Profile > Personal Access Tokens', feature: true, js: true do let(:user) { create(:user) } before do @@ -13,18 +13,22 @@ describe 'Profile > Personal Access Tokens', feature: true do fill_in "Name", with: FFaker::Product.brand expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - active_personal_access_tokens = find(".table.active-personal-access-tokens").native.inner_html + active_personal_access_tokens = find(".table.active-personal-access-tokens").native['innerHTML'] expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) expect(active_personal_access_tokens).to match("Never") expect(active_personal_access_tokens).to match(PersonalAccessToken.last.token) fill_in "Name", with: FFaker::Product.brand - fill_in "Expires at", with: 5.days.from_now + + # Set date to 1st of next month + find("a[title='Next']").click + click_on "1" + expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - active_personal_access_tokens = find(".table.active-personal-access-tokens").native.inner_html + active_personal_access_tokens = find(".table.active-personal-access-tokens").native['innerHTML'] expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) - expect(active_personal_access_tokens).to match(5.days.from_now.to_date.to_s) + expect(active_personal_access_tokens).to match(Date.today.next_month.at_beginning_of_month.to_s) expect(active_personal_access_tokens).to match(PersonalAccessToken.last.token) end end @@ -35,7 +39,7 @@ describe 'Profile > Personal Access Tokens', feature: true do visit profile_personal_access_tokens_path click_on "Revoke" - inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native.inner_html + inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native['innerHTML'] expect(inactive_personal_access_tokens).to match(personal_access_token.name) expect(inactive_personal_access_tokens).to match(personal_access_token.token) end @@ -44,7 +48,7 @@ describe 'Profile > Personal Access Tokens', feature: true do personal_access_token = create(:personal_access_token, expires_at: 5.days.ago, user: user) visit profile_personal_access_tokens_path - inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native.inner_html + inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native['innerHTML'] expect(inactive_personal_access_tokens).to match(personal_access_token.name) expect(inactive_personal_access_tokens).to match(personal_access_token.token) end diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb new file mode 100644 index 0000000000..3e80a48175 --- /dev/null +++ b/spec/models/personal_access_token_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe PersonalAccessToken, models: true do + describe ".generate" do + it "generates a random token" do + personal_access_token = PersonalAccessToken.generate({}) + expect(personal_access_token.token).to be_present + end + + it "doesn't save the record" do + personal_access_token = PersonalAccessToken.generate({}) + expect(personal_access_token).to_not be_persisted + end + end +end From 2768e99ac386598480a7ac5490deee644125dfa1 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 28 Apr 2016 17:18:49 +0530 Subject: [PATCH 023/195] Add documentation for personal access tokens. --- doc/api/README.md | 63 ++++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/doc/api/README.md b/doc/api/README.md index ff039f1886..0e9dc7acfe 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -37,13 +37,11 @@ following locations: ## Authentication -All API requests require authentication. You need to pass a `private_token` -parameter via query string or header. If passed as a header, the header name -must be `PRIVATE-TOKEN` (uppercase and with a dash instead of an underscore). -You can find or reset your private token in your account page (`/profile/account`). +All API requests require authentication via a token. There are three types of tokens +available: private tokens, OAuth 2 tokens, and personal access tokens. -If `private_token` is invalid or omitted, then an error message will be -returned with status code `401`: +If a token is invalid or omitted, an error message will be returned with +status code `401`: ```json { @@ -51,6 +49,39 @@ returned with status code `401`: } ``` +### Private Tokens + +You need to pass a `private_token` parameter via query string or header. If passed as a +header, the header name must be `PRIVATE-TOKEN` (uppercase and with a dash instead of +an underscore). You can find or reset your private token in your account page +(`/profile/account`). + +### OAuth 2 Tokens + +You can use an OAuth 2 token to authenticate with the API by passing it either in the +`access_token` parameter or in the `Authorization` header. + +Example of using the OAuth2 token in the header: + +```shell +curl -H "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v3/projects +``` + +Read more about [GitLab as an OAuth2 client](oauth2.md). + +### Personal Access Tokens + +> **Note:** This feature was [introduced][ce-3749] in GitLab 8.8 + +You can create as many personal access tokens as you like from your GitLab +profile (`/profile/personal_access_tokens`); perhaps one for each application +that needs access to the GitLab API. + +Once you have your token, pass it to the API using either the `personal_access_token` +parameter or the `PERSONAL-ACCESS-TOKEN` header. + +## Basic Usage + API requests should be prefixed with `api` and the API version. The API version is defined in [`lib/api.rb`][lib-api-url]. @@ -69,25 +100,6 @@ curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/ The API uses JSON to serialize data. You don't need to specify `.json` at the end of an API URL. -## Authentication with OAuth2 token - -Instead of the `private_token` you can transmit the OAuth2 access token as a -header or as a parameter. - -Example of OAuth2 token as a parameter: - -```shell -curl https://gitlab.example.com/api/v3/user?access_token=OAUTH-TOKEN -``` - -Example of OAuth2 token as a header: - -```shell -curl -H "Authorization: Bearer OAUTH-TOKEN" https://example.com/api/v3/user -``` - -Read more about [GitLab as an OAuth2 client](oauth2.md). - ## Status codes The API is designed to return different status codes according to context and @@ -323,3 +335,4 @@ programming languages. Visit the [GitLab website] for a complete list. [GitLab website]: https://about.gitlab.com/applications/#api-clients "Clients using the GitLab API" [lib-api-url]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/lib/api/api.rb +[ce-3749]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3749 From 14964308dc4ca0d62dd12231529abbcfe940f603 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Tue, 3 May 2016 12:07:06 +0100 Subject: [PATCH 024/195] add gfm autocomplete for labels --- .../javascripts/gfm_auto_complete.js.coffee | 25 +++++++++++++++++++ app/controllers/projects_controller.rb | 1 + app/services/projects/autocomplete_service.rb | 4 +++ 3 files changed, 30 insertions(+) diff --git a/app/assets/javascripts/gfm_auto_complete.js.coffee b/app/assets/javascripts/gfm_auto_complete.js.coffee index 61e3f811e7..6d3f4ec975 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.coffee +++ b/app/assets/javascripts/gfm_auto_complete.js.coffee @@ -14,6 +14,10 @@ GitLab.GfmAutoComplete = Members: template: '
  • ${username} ${title}
  • ' + Labels: + template: '
  • ${title}
    +
  • ' + # Issues and MergeRequests Issues: template: '
  • ${id} ${title}
  • ' @@ -94,6 +98,25 @@ GitLab.GfmAutoComplete = title: sanitize(m.title) search: "#{m.iid} #{m.title}" + @input.atwho + at: '~' + alias: 'labels' + searchKey: 'search' + displayTpl: @Labels.template + insertTpl: '${atwho-at}${title}' + callbacks: + beforeSave: (merges) -> + sanitizeLabelTitle = (title)-> + if /\w+\s+\w+/g.test(title) + "\"#{sanitize(title)}\"" + else + sanitize(title) + + $.map merges, (m) -> + title: sanitizeLabelTitle(m.title) + color: m.color + search: "#{m.title}" + destroyAtWho: -> @input.atwho('destroy') @@ -109,3 +132,5 @@ GitLab.GfmAutoComplete = @input.atwho 'load', 'mergerequests', data.mergerequests # load emojis @input.atwho 'load', ':', data.emojis + # load labels + @input.atwho 'load', '~', data.labels diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 3768efe142..4d7a3bfe64 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -148,6 +148,7 @@ class ProjectsController < Projects::ApplicationController emojis: AwardEmoji.urls, issues: autocomplete.issues, mergerequests: autocomplete.merge_requests, + labels: autocomplete.labels, members: participants } diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index ba50305dbd..826b10899d 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -7,5 +7,9 @@ module Projects def merge_requests @project.merge_requests.opened.select([:iid, :title]) end + + def labels + @project.labels.select([:title, :color]) + end end end From 68da2ac9a0fa6f33b46f0bfbc849043605c2d878 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Tue, 3 May 2016 12:09:13 +0100 Subject: [PATCH 025/195] add entry to CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index b6527780bb..670f7fa3ab 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,6 +12,7 @@ v 8.8.0 (unreleased) - Display informative message when new milestone is created - Replace Devise Async with Devise ActiveJob integration. !3902 (Connor Shea) - Allow "NEWS" and "CHANGES" as alternative names for CHANGELOG. !3768 (Connor Shea) + - Added Gfm autocomplete for labels - Added button to toggle whitespaces changes on diff view - Backport GitLab Enterprise support from EE - Files over 5MB can only be viewed in their raw form, files over 1MB without highlighting !3718 From d915e7d5cad99b8971e65d30accc8bc7a05fecbc Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 11 May 2016 10:16:23 +0530 Subject: [PATCH 026/195] Reuse the private token param and header for personal access tokens. - https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3749#note_11626427 - Personal access tokens are still a separate entity as far as the codebase is concerned - they just happen to use the same entry point as private tokens. - Update tests and documentation to reflect this change --- app/controllers/application_controller.rb | 2 +- doc/api/README.md | 4 ++-- lib/api/helpers.rb | 4 ++-- spec/controllers/application_controller_spec.rb | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 72ba1a85cf..b26afb42e7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -80,7 +80,7 @@ class ApplicationController < ActionController::Base end def authenticate_user_from_personal_access_token! - token_string = params[:personal_access_token].presence || request.headers['PERSONAL_ACCESS_TOKEN'].presence + token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence personal_access_token = PersonalAccessToken.active.find_by_token(token_string) user = personal_access_token && personal_access_token.user diff --git a/doc/api/README.md b/doc/api/README.md index 0e9dc7acfe..276816b280 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -77,8 +77,8 @@ You can create as many personal access tokens as you like from your GitLab profile (`/profile/personal_access_tokens`); perhaps one for each application that needs access to the GitLab API. -Once you have your token, pass it to the API using either the `personal_access_token` -parameter or the `PERSONAL-ACCESS-TOKEN` header. +Once you have your token, pass it to the API using either the `private_token` +parameter or the `PRIVATE-TOKEN` header. ## Basic Usage diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index de9a1b0eb9..68642e2d8a 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -4,8 +4,8 @@ module API PRIVATE_TOKEN_PARAM = :private_token SUDO_HEADER ="HTTP_SUDO" SUDO_PARAM = :sudo - PERSONAL_ACCESS_TOKEN_PARAM = :personal_access_token - PERSONAL_ACCESS_TOKEN_HEADER = "HTTP_PERSONAL_ACCESS_TOKEN" + PERSONAL_ACCESS_TOKEN_PARAM = PRIVATE_TOKEN_PARAM + PERSONAL_ACCESS_TOKEN_HEADER = PRIVATE_TOKEN_HEADER def parse_boolean(value) [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value) diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index e8bdbf1afb..d7835dc6e2 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -72,20 +72,20 @@ describe ApplicationController do let(:personal_access_token) { create(:personal_access_token, user: user) } it "logs the user in when the 'personal_access_token' param is populated with the personal access token" do - get :index, personal_access_token: personal_access_token.token + get :index, private_token: personal_access_token.token expect(response.status).to eq(200) expect(response.body).to eq('authenticated') end it "logs the user in when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do - @request.headers["PERSONAL_ACCESS_TOKEN"] = personal_access_token.token + @request.headers["PRIVATE-TOKEN"] = personal_access_token.token get :index expect(response.status).to eq(200) expect(response.body).to eq('authenticated') end it "doesn't log the user in otherwise" do - get :index, personal_access_token: "token" + get :index, private_token: "token" expect(response.status).to_not eq(200) expect(response.body).to_not eq('authenticated') end From 70add1388f514b353d92d2e6a1db0dc173290946 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 11 May 2016 10:25:21 +0530 Subject: [PATCH 027/195] Minor fixes after a final look at the diff. - Spaces around `{` and `}` in HAML. - Typo in CHANGELOG. - Remove i18n. --- CHANGELOG | 2 +- .../profiles/personal_access_tokens/index.html.haml | 10 +++++----- config/locales/en.yml | 4 ---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 5b52977a9b..d8d8a36e4b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,7 +12,7 @@ v 8.8.0 (unreleased) - GitAccess#protected_tag? no longer loads all tags just to check if a single one exists - Reduce delay in destroying a project from 1-minute to immediately - Make build status canceled if any of the jobs was canceled and none failed - - Allow authentication using personal acces tokens + - Allow authentication using personal access tokens - Upgrade Sidekiq to 4.1.2 - Sanitize repo paths in new project error message - Bump mail_room to 0.7.0 to fix stuck IDLE connections diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index f7482d2c87..0f51e8cd8b 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -46,9 +46,9 @@ %tr %td= token.name %td.input-group.personal-access-tokens-token-column - %input.form-control{type: "text", value: token.token, readonly: true} + %input.form-control{ type: "text", value: token.token, readonly: true } %div.input-group-btn - %button.btn.btn-default{type: "button", data: {clipboard_text: token.token}} + %button.btn.btn-default{ type: "button", data: { clipboard_text: token.token } } %i.fa.fa-clipboard %td= token.created_at.to_date - if token.expires_at.present? @@ -56,7 +56,7 @@ - else %td %span.personal-access-tokens-never-expires-label Never - %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: {confirm: t('profile.personal_access_tokens.revoke.confirmation')} + %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: { confirm: "Are you sure? This cannot be undone." } - else %span You don't have any active tokens yet. @@ -78,9 +78,9 @@ %tr %td= token.name %td.input-group.personal-access-tokens-token-column - %input.form-control{type: "text", value: token.token, readonly: true} + %input.form-control{ type: "text", value: token.token, readonly: true } %div.input-group-btn - %button.btn.btn-default{type: "button", data: {clipboard_text: token.token}} + %button.btn.btn-default{ type: "button", data: { clipboard_text: token.token } } %i.fa.fa-clipboard %td= token.created_at.to_date diff --git a/config/locales/en.yml b/config/locales/en.yml index b5b8c4467b..cedb5e207b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,7 +12,3 @@ en: pagination: previous: "Prev" next: "Next" - profile: - personal_access_tokens: - revoke: - confirmation: "Are you sure? This cannot be undone." From 05b319b0b45288cbbe0bce15bf7bed7f58f6cf76 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 1 Jun 2016 14:04:38 +0530 Subject: [PATCH 028/195] Perform private token and personal access token authentication in the same `before_action`. - So that the check for valid personal access tokens happens only if private token auth fails. --- app/controllers/application_controller.rb | 38 +++---- .../application_controller_spec.rb | 100 +++++++++--------- 2 files changed, 67 insertions(+), 71 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b26afb42e7..9dbaba00ff 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,8 +7,7 @@ class ApplicationController < ActionController::Base include GitlabRoutingHelper include PageLayoutHelper - before_action :authenticate_user_from_private_token! - before_action :authenticate_user_from_personal_access_token! + before_action :authenticate_user_from_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :reject_blocked! @@ -64,26 +63,8 @@ class ApplicationController < ActionController::Base end end - # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example - # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 - def authenticate_user_from_private_token! - user_token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence - user = user_token && User.find_by_authentication_token(user_token.to_s) - - if user - # Notice we are passing store false, so the user is not - # actually stored in the session and a token is needed - # for every request. If you want the token to work as a - # sign in token, you can simply remove store: false. - sign_in user, store: false - end - end - - def authenticate_user_from_personal_access_token! - token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence - personal_access_token = PersonalAccessToken.active.find_by_token(token_string) - user = personal_access_token && personal_access_token.user - + def authenticate_user_from_token! + user = get_user_from_private_token || get_user_from_personal_access_token if user # Notice we are passing store false, so the user is not # actually stored in the session and a token is needed @@ -383,4 +364,17 @@ class ApplicationController < ActionController::Base (controller_name == 'groups' && action_name == page_type) || (controller_name == 'dashboard' && action_name == page_type) end + + # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example + # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 + def get_user_from_private_token + user_token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence + User.find_by_authentication_token(user_token.to_s) if user_token + end + + def get_user_from_personal_access_token + token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence + personal_access_token = PersonalAccessToken.active.find_by_token(token_string) + personal_access_token.user if personal_access_token + end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index d7835dc6e2..90dbd1183e 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -31,63 +31,65 @@ describe ApplicationController do end end - describe "#authenticate_user_from_private_token!" do - controller(ApplicationController) do - def index - render text: "authenticated" + describe "#authenticate_user_from_token!" do + describe "authenticating a user from a private token" do + controller(ApplicationController) do + def index + render text: "authenticated" + end + end + + let(:user) { create(:user) } + + it "logs the user in when the 'private_token' param is populated with the private token" do + get :index, private_token: user.private_token + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end + + it "logs the user in when the 'PRIVATE-TOKEN' header is populated with the private token" do + @request.headers['PRIVATE-TOKEN'] = user.private_token + get :index + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end + + it "doesn't log the user in otherwise" do + @request.headers['PRIVATE-TOKEN'] = "token" + get :index, private_token: "token", authenticity_token: "token" + expect(response.status).to_not eq(200) + expect(response.body).to_not eq("authenticated") end end - let(:user) { create(:user) } - - it "logs the user in when the 'private_token' param is populated with the private token" do - get :index, private_token: user.private_token - expect(response.status).to eq(200) - expect(response.body).to eq("authenticated") - end - - it "logs the user in when the 'PRIVATE-TOKEN' header is populated with the private token" do - @request.headers['PRIVATE-TOKEN'] = user.private_token - get :index - expect(response.status).to eq(200) - expect(response.body).to eq("authenticated") - end - - it "doesn't log the user in otherwise" do - @request.headers['PRIVATE-TOKEN'] = "token" - get :index, private_token: "token", authenticity_token: "token" - expect(response.status).to_not eq(200) - expect(response.body).to_not eq("authenticated") - end - end - - describe "#authenticate_user_from_personal_access_token!" do - controller(ApplicationController) do - def index - render text: 'authenticated' + describe "authenticating a user from a personal access token" do + controller(ApplicationController) do + def index + render text: 'authenticated' + end end - end - let(:user) { create(:user) } - let(:personal_access_token) { create(:personal_access_token, user: user) } + let(:user) { create(:user) } + let(:personal_access_token) { create(:personal_access_token, user: user) } - it "logs the user in when the 'personal_access_token' param is populated with the personal access token" do - get :index, private_token: personal_access_token.token - expect(response.status).to eq(200) - expect(response.body).to eq('authenticated') - end + it "logs the user in when the 'personal_access_token' param is populated with the personal access token" do + get :index, private_token: personal_access_token.token + expect(response.status).to eq(200) + expect(response.body).to eq('authenticated') + end - it "logs the user in when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do - @request.headers["PRIVATE-TOKEN"] = personal_access_token.token - get :index - expect(response.status).to eq(200) - expect(response.body).to eq('authenticated') - end + it "logs the user in when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do + @request.headers["PRIVATE-TOKEN"] = personal_access_token.token + get :index + expect(response.status).to eq(200) + expect(response.body).to eq('authenticated') + end - it "doesn't log the user in otherwise" do - get :index, private_token: "token" - expect(response.status).to_not eq(200) - expect(response.body).to_not eq('authenticated') + it "doesn't log the user in otherwise" do + get :index, private_token: "token" + expect(response.status).to_not eq(200) + expect(response.body).to_not eq('authenticated') + end end end end From 6d444331764fec1910d15b300d89b6246a1f83ea Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 1 Jun 2016 14:09:17 +0530 Subject: [PATCH 029/195] Don't look for personal access tokens in the DB when the parameter/header is not passed. --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9dbaba00ff..17bd980b45 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -374,7 +374,7 @@ class ApplicationController < ActionController::Base def get_user_from_personal_access_token token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence - personal_access_token = PersonalAccessToken.active.find_by_token(token_string) + personal_access_token = PersonalAccessToken.active.find_by_token(token_string) if token_string personal_access_token.user if personal_access_token end end From c75aea5e4dc6f4420e7576a5c367a9e035f63646 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 1 Jun 2016 14:31:16 +0530 Subject: [PATCH 030/195] Fix minor issues with the personal access tokens implementation. - Use the `:personal_access_token` param root instead of `personal_access_token_params`, because we aren't using the `personal_access_token` param for authentication anymore (we're using `private_token` instead). - Use `build` to instantiate a `PersonalAccessToken` - Use better-formatted dates --- .../profiles/personal_access_tokens_controller.rb | 6 ++---- .../profiles/personal_access_tokens/index.html.haml | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index af6def25e7..1ad1c11b73 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -2,7 +2,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def index @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive - @personal_access_token = PersonalAccessToken.new(user: @user) + @personal_access_token = current_user.personal_access_tokens.build end def create @@ -28,8 +28,6 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController private def personal_access_token_params - # We aren't using `personal_access_token` as the root param because the authentication - # system expects to find a token string there - it's off-limits to us. - params.require(:personal_access_token_params).permit(:name, :expires_at) + params.require(:personal_access_token).permit(:name, :expires_at) end end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 0f51e8cd8b..b4468dd983 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -12,7 +12,7 @@ Add a Personal Access Token %p.profile-settings-content Pick a name for the application, and we'll give you a unique token. - = form_for [:profile, @personal_access_token], as: "personal_access_token_params", + = form_for [:profile, @personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| .form-group @@ -29,7 +29,7 @@ %hr - %h5= "Active Personal Access Tokens (#{@active_personal_access_tokens.length})" + %h5 Active Personal Access Tokens (#{@active_personal_access_tokens.length}) - if @active_personal_access_tokens.present? .table-responsive @@ -50,9 +50,9 @@ %div.input-group-btn %button.btn.btn-default{ type: "button", data: { clipboard_text: token.token } } %i.fa.fa-clipboard - %td= token.created_at.to_date + %td= token.created_at.to_date.to_s(:medium) - if token.expires_at.present? - %td= token.expires_at.to_date + %td= token.expires_at.to_date.to_s(:medium) - else %td %span.personal-access-tokens-never-expires-label Never @@ -63,7 +63,7 @@ %hr - %h5= "Inactive Personal Access Tokens (#{@inactive_personal_access_tokens.length})" + %h5 Inactive Personal Access Tokens (#{@inactive_personal_access_tokens.length}) - if @inactive_personal_access_tokens.present? .table-responsive @@ -82,7 +82,7 @@ %div.input-group-btn %button.btn.btn-default{ type: "button", data: { clipboard_text: token.token } } %i.fa.fa-clipboard - %td= token.created_at.to_date + %td= token.created_at.to_date.to_s(:medium) - else %span No inactive tokens. From 4d50d8a6e3b8ae1521617df32af4cad18385fb9f Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 2 Jun 2016 08:27:47 +0530 Subject: [PATCH 031/195] Only show a personal access token right after its creation. --- app/assets/stylesheets/pages/profile.scss | 8 +++++ .../personal_access_tokens_controller.rb | 3 +- .../personal_access_tokens/index.html.haml | 26 +++++++-------- .../profiles/personal_access_tokens_spec.rb | 32 ++++++++++++------- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 53f16c13ec..69bc74228e 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -218,6 +218,14 @@ max-width: 500px } +.created-personal-access-token { + margin: 15px 15px 0 0; + pre { + max-width: 400px; + display: inline; + } +} + .user-profile { @media (max-width: $screen-xs-max) { .cover-block { diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 1ad1c11b73..81f2390a56 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -9,7 +9,8 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController @personal_access_token = current_user.personal_access_tokens.generate(personal_access_token_params) if @personal_access_token.save - redirect_to profile_personal_access_tokens_path, notice: "Created personal access token!" + flash[:personal_access_token] = @personal_access_token.token + redirect_to profile_personal_access_tokens_path else render :index end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index b4468dd983..503f88efb4 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -8,6 +8,16 @@ %p You can generate a personal access token for each application you use that needs access to GitLab. .col-lg-9 + + - if flash[:personal_access_token] + .panel.panel-success + .panel-heading Success! + .panel-body + Your new personal access token has been created. Make sure to save it - you can't see it again on this page. + .created-personal-access-token + %pre= flash[:personal_access_token] + = clipboard_button(clipboard_text: flash[:personal_access_token]) + %h5.prepend-top-0 Add a Personal Access Token %p.profile-settings-content @@ -37,7 +47,6 @@ %thead %tr %th Name - %th Token %th Created %th Expires %th Actions @@ -45,11 +54,6 @@ - @active_personal_access_tokens.each do |token| %tr %td= token.name - %td.input-group.personal-access-tokens-token-column - %input.form-control{ type: "text", value: token.token, readonly: true } - %div.input-group-btn - %button.btn.btn-default{ type: "button", data: { clipboard_text: token.token } } - %i.fa.fa-clipboard %td= token.created_at.to_date.to_s(:medium) - if token.expires_at.present? %td= token.expires_at.to_date.to_s(:medium) @@ -71,17 +75,11 @@ %thead %tr %th Name - %th Token %th Created %tbody - @inactive_personal_access_tokens.each do |token| %tr %td= token.name - %td.input-group.personal-access-tokens-token-column - %input.form-control{ type: "text", value: token.token, readonly: true } - %div.input-group-btn - %button.btn.btn-default{ type: "button", data: { clipboard_text: token.token } } - %i.fa.fa-clipboard %td= token.created_at.to_date.to_s(:medium) - else @@ -91,5 +89,5 @@ :javascript $(".datepicker").datepicker({ dateFormat: "yy-mm-dd", - onSelect: function(dateText, inst) { $("#personal_access_token_params_expires_at").val(dateText) } - }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#personal_access_token_params_expires_at').val())); + onSelect: function(dateText, inst) { $("#personal_access_token_expires_at").val(dateText) } + }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#personal_access_token_expires_at').val())); diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index e9fbeefae7..095c7d60e2 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -3,21 +3,35 @@ require 'spec_helper' describe 'Profile > Personal Access Tokens', feature: true, js: true do let(:user) { create(:user) } + def active_personal_access_tokens + find(".table.active-personal-access-tokens").native['innerHTML'] + end + + def inactive_personal_access_tokens + find(".table.inactive-personal-access-tokens").native['innerHTML'] + end + + def created_personal_access_token + find(".created-personal-access-token pre").native['innerHTML'] + end + before do login_as(user) end describe "token creation" do - it "allows creation of a token with an optional expiry date" do + it "allows creation of a token" do visit profile_personal_access_tokens_path fill_in "Name", with: FFaker::Product.brand - expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - active_personal_access_tokens = find(".table.active-personal-access-tokens").native['innerHTML'] + expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) expect(active_personal_access_tokens).to match("Never") - expect(active_personal_access_tokens).to match(PersonalAccessToken.last.token) + end + it "allows creation of a token with an expiry date" do + visit profile_personal_access_tokens_path fill_in "Name", with: FFaker::Product.brand # Set date to 1st of next month @@ -25,11 +39,9 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do click_on "1" expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - - active_personal_access_tokens = find(".table.active-personal-access-tokens").native['innerHTML'] + expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) - expect(active_personal_access_tokens).to match(Date.today.next_month.at_beginning_of_month.to_s) - expect(active_personal_access_tokens).to match(PersonalAccessToken.last.token) + expect(active_personal_access_tokens).to match(Date.today.next_month.at_beginning_of_month.to_s(:medium)) end end @@ -39,18 +51,14 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do visit profile_personal_access_tokens_path click_on "Revoke" - inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native['innerHTML'] expect(inactive_personal_access_tokens).to match(personal_access_token.name) - expect(inactive_personal_access_tokens).to match(personal_access_token.token) end it "moves expired tokens to the 'inactive' section" do personal_access_token = create(:personal_access_token, expires_at: 5.days.ago, user: user) visit profile_personal_access_tokens_path - inactive_personal_access_tokens = find(".table.inactive-personal-access-tokens").native['innerHTML'] expect(inactive_personal_access_tokens).to match(personal_access_token.name) - expect(inactive_personal_access_tokens).to match(personal_access_token.token) end end end From a1295d8ebef223eb0a87bc9e1660efcb9147599c Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 08:58:15 +0530 Subject: [PATCH 032/195] Don't use `natve['innerHTML']` in the feature spec. - The `have_text` matcher works fine. --- .../profiles/personal_access_tokens_spec.rb | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 095c7d60e2..14f2a7c9a2 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -4,15 +4,15 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do let(:user) { create(:user) } def active_personal_access_tokens - find(".table.active-personal-access-tokens").native['innerHTML'] + find(".table.active-personal-access-tokens") end def inactive_personal_access_tokens - find(".table.inactive-personal-access-tokens").native['innerHTML'] + find(".table.inactive-personal-access-tokens") end def created_personal_access_token - find(".created-personal-access-token pre").native['innerHTML'] + find(".created-personal-access-token pre") end before do @@ -25,9 +25,9 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do fill_in "Name", with: FFaker::Product.brand expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) - expect(active_personal_access_tokens).to match("Never") + expect(created_personal_access_token).to have_text(PersonalAccessToken.last.token) + expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) + expect(active_personal_access_tokens).to have_text("Never") end it "allows creation of a token with an expiry date" do @@ -39,9 +39,9 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do click_on "1" expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(active_personal_access_tokens).to match(PersonalAccessToken.last.name) - expect(active_personal_access_tokens).to match(Date.today.next_month.at_beginning_of_month.to_s(:medium)) + expect(created_personal_access_token).to have_text(PersonalAccessToken.last.token) + expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) + expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) end end @@ -51,14 +51,14 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do visit profile_personal_access_tokens_path click_on "Revoke" - expect(inactive_personal_access_tokens).to match(personal_access_token.name) + expect(inactive_personal_access_tokens).to have_text(personal_access_token.name) end it "moves expired tokens to the 'inactive' section" do personal_access_token = create(:personal_access_token, expires_at: 5.days.ago, user: user) visit profile_personal_access_tokens_path - expect(inactive_personal_access_tokens).to match(personal_access_token.name) + expect(inactive_personal_access_tokens).to have_text(personal_access_token.name) end end end From b4b024857783e1fc39424fdf631b722ee1dfd195 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:00:39 +0530 Subject: [PATCH 033/195] Parts of spec names with "when" should be contexts. --- .../application_controller_spec.rb | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 90dbd1183e..ff596e7c2a 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -41,17 +41,22 @@ describe ApplicationController do let(:user) { create(:user) } - it "logs the user in when the 'private_token' param is populated with the private token" do - get :index, private_token: user.private_token - expect(response.status).to eq(200) - expect(response.body).to eq("authenticated") + context "when the 'private_token' param is populated with the private token" do + it "logs the user in" do + get :index, private_token: user.private_token + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end end - it "logs the user in when the 'PRIVATE-TOKEN' header is populated with the private token" do - @request.headers['PRIVATE-TOKEN'] = user.private_token - get :index - expect(response.status).to eq(200) - expect(response.body).to eq("authenticated") + + context "when the 'PRIVATE-TOKEN' header is populated with the private token" do + it "logs the user in" do + @request.headers['PRIVATE-TOKEN'] = user.private_token + get :index + expect(response.status).to eq(200) + expect(response.body).to eq("authenticated") + end end it "doesn't log the user in otherwise" do @@ -72,17 +77,21 @@ describe ApplicationController do let(:user) { create(:user) } let(:personal_access_token) { create(:personal_access_token, user: user) } - it "logs the user in when the 'personal_access_token' param is populated with the personal access token" do - get :index, private_token: personal_access_token.token - expect(response.status).to eq(200) - expect(response.body).to eq('authenticated') + context "when the 'personal_access_token' param is populated with the personal access token" do + it "logs the user in" do + get :index, private_token: personal_access_token.token + expect(response.status).to eq(200) + expect(response.body).to eq('authenticated') + end end - it "logs the user in when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do - @request.headers["PRIVATE-TOKEN"] = personal_access_token.token - get :index - expect(response.status).to eq(200) - expect(response.body).to eq('authenticated') + context "when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do + it "logs the user in" do + @request.headers["PRIVATE-TOKEN"] = personal_access_token.token + get :index + expect(response.status).to eq(200) + expect(response.body).to eq('authenticated') + end end it "doesn't log the user in otherwise" do From 399a633061577b8a2ca95c29ce1cfe0abeac4779 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:07:37 +0530 Subject: [PATCH 034/195] Fix minor styling issues. - No "Actions" label necessary - `%td` can be moved out of `if/else` - Page header should be "Profile Settings", not "Personal Access Tokens" - "You don't have any tokens" message should be styled consistently --- .../personal_access_tokens/index.html.haml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 503f88efb4..68f2813bc1 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -1,12 +1,11 @@ - page_title "Personal Access Tokens" -- header_title page_title, profile_personal_access_tokens_path .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 = page_title %p - You can generate a personal access token for each application you use that needs access to GitLab. + You can generate a personal access token for each application you use that needs access to the GitLab API. .col-lg-9 - if flash[:personal_access_token] @@ -49,21 +48,22 @@ %th Name %th Created %th Expires - %th Actions + %th %tbody - @active_personal_access_tokens.each do |token| %tr %td= token.name %td= token.created_at.to_date.to_s(:medium) - - if token.expires_at.present? - %td= token.expires_at.to_date.to_s(:medium) - - else - %td + %td + - if token.expires_at.present? + = token.expires_at.to_date.to_s(:medium) + - else %span.personal-access-tokens-never-expires-label Never %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: { confirm: "Are you sure? This cannot be undone." } - else - %span You don't have any active tokens yet. + .settings-message.text-center + You don't have any active tokens yet. %hr @@ -83,7 +83,8 @@ %td= token.created_at.to_date.to_s(:medium) - else - %span No inactive tokens. + .settings-message.text-center + There are no inactive tokens. :javascript From ffe111c1e22b0cce827c297fea62dfb0bd91326a Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:23:16 +0530 Subject: [PATCH 035/195] Display appropriate errors when personal access token creation/revocation fails. --- .../profiles/personal_access_tokens_controller.rb | 12 +++++++++--- .../profiles/personal_access_tokens/index.html.haml | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 81f2390a56..a1545a5dd0 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -1,7 +1,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController + before_action :load_personal_access_tokens, only: :index + def index - @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) - @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive @personal_access_token = current_user.personal_access_tokens.build end @@ -12,6 +12,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController flash[:personal_access_token] = @personal_access_token.token redirect_to profile_personal_access_tokens_path else + load_personal_access_tokens render :index end end @@ -22,7 +23,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController if @personal_access_token.revoke! redirect_to profile_personal_access_tokens_path, notice: "Revoked personal access token #{@personal_access_token.name}!" else - render :index + redirect_to profile_personal_access_tokens_path, alert: "Could not revoke personal access token #{@personal_access_token.name}." end end @@ -31,4 +32,9 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def personal_access_token_params params.require(:personal_access_token).permit(:name, :expires_at) end + + def load_personal_access_tokens + @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) + @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive + end end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 68f2813bc1..64e632b20e 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -24,6 +24,8 @@ = form_for [:profile, @personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| + = form_errors(@personal_access_token) + .form-group = f.label :name, class: 'label-light' = f.text_field :name, class: "form-control", required: true From 1a416720a2261f453ec4ce539fa61c1c32769aea Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:31:56 +0530 Subject: [PATCH 036/195] Update CHANGELOG. --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 547921a077..57a006869c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -23,6 +23,7 @@ v 8.9.0 (unreleased) - Link to blank group icon doesn't throw a 404 anymore - Remove 'main language' feature - Pipelines can be canceled only when there are running builds + - Allow authentication using personal access tokens - Use downcased path to container repository as this is expected path by Docker - Projects pending deletion will render a 404 page - Measure queue duration between gitlab-workhorse and Rails @@ -92,7 +93,6 @@ v 8.8.0 - GitAccess#protected_tag? no longer loads all tags just to check if a single one exists - Reduce delay in destroying a project from 1-minute to immediately - Make build status canceled if any of the jobs was canceled and none failed - - Allow authentication using personal access tokens - Upgrade Sidekiq to 4.1.2 - Added /health_check endpoint for checking service status - Make 'upcoming' filter for milestones work better across projects From 3adf125a155fd04fbba4f0882c739eae1cc73e15 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:53:49 +0530 Subject: [PATCH 037/195] Add tests for errors while creating/revoking personal access tokens. --- .../profiles/personal_access_tokens_spec.rb | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 14f2a7c9a2..5bba08d005 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -15,6 +15,12 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do find(".created-personal-access-token pre") end + def disallow_personal_access_token_saves! + allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false) + errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") } + allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors) + end + before do login_as(user) end @@ -43,11 +49,23 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) end + + context "when creation fails" do + it "displays an error message" do + disallow_personal_access_token_saves! + visit profile_personal_access_tokens_path + fill_in "Name", with: FFaker::Product.brand + + expect { click_on "Add Personal Access Token" }.not_to change { PersonalAccessToken.count } + expect(page).to have_content("Name cannot be nil") + end + end end describe "inactive tokens" do + let!(:personal_access_token) { create(:personal_access_token, user: user) } + it "allows revocation of an active token" do - personal_access_token = create(:personal_access_token, user: user) visit profile_personal_access_tokens_path click_on "Revoke" @@ -55,10 +73,21 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end it "moves expired tokens to the 'inactive' section" do - personal_access_token = create(:personal_access_token, expires_at: 5.days.ago, user: user) + personal_access_token.update(expires_at: 5.days.ago) visit profile_personal_access_tokens_path expect(inactive_personal_access_tokens).to have_text(personal_access_token.name) end + + context "when revocation fails" do + it "displays an error message" do + disallow_personal_access_token_saves! + visit profile_personal_access_tokens_path + + expect { click_on "Revoke" }.not_to change { PersonalAccessToken.inactive.count } + expect(active_personal_access_tokens).to have_text(personal_access_token.name) + expect(page).to have_content("Could not revoke") + end + end end end From 0dff6fd7148957fa94d2626e3912cd929ba150d3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 10:10:58 +0530 Subject: [PATCH 038/195] Fix rubocop spec. --- app/controllers/application_controller.rb | 2 +- spec/controllers/application_controller_spec.rb | 8 ++++---- spec/models/personal_access_token_spec.rb | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c8baea2579..23a0e16ca4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -368,7 +368,7 @@ class ApplicationController < ActionController::Base # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 def get_user_from_private_token - user_token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence + user_token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence User.find_by_authentication_token(user_token.to_s) if user_token end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index ff596e7c2a..ff5b391627 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -62,8 +62,8 @@ describe ApplicationController do it "doesn't log the user in otherwise" do @request.headers['PRIVATE-TOKEN'] = "token" get :index, private_token: "token", authenticity_token: "token" - expect(response.status).to_not eq(200) - expect(response.body).to_not eq("authenticated") + expect(response.status).not_to eq(200) + expect(response.body).not_to eq("authenticated") end end @@ -96,8 +96,8 @@ describe ApplicationController do it "doesn't log the user in otherwise" do get :index, private_token: "token" - expect(response.status).to_not eq(200) - expect(response.body).to_not eq('authenticated') + expect(response.status).not_to eq(200) + expect(response.body).not_to eq('authenticated') end end end diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 3e80a48175..46eb71cef1 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -9,7 +9,7 @@ describe PersonalAccessToken, models: true do it "doesn't save the record" do personal_access_token = PersonalAccessToken.generate({}) - expect(personal_access_token).to_not be_persisted + expect(personal_access_token).not_to be_persisted end end end From af2f56f8f742e00ddb298fadea763fd0fe7054f0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 12:39:15 +0200 Subject: [PATCH 039/195] Refactor ci commit pipeline to prevent implicit saves --- app/models/ci/commit.rb | 16 +++-------- app/services/ci/create_pipeline_service.rb | 28 +++++++++---------- app/services/create_commit_builds_service.rb | 2 +- .../create_commit_builds_service_spec.rb | 2 +- 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index f22b573a94..c682f3e570 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -13,7 +13,7 @@ module Ci validate :valid_commit_sha # Invalidate object and save if when touched - after_touch :update_state + after_touch :update_state! def self.truncate_sha(sha) sha[0...8] @@ -135,10 +135,10 @@ module Ci @config_processor ||= begin Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e - save_yaml_error(e.message) + self.yaml_errors = (e.message) nil rescue - save_yaml_error("Undefined error") + self.yaml_errors = 'Undefined error' nil end end @@ -159,9 +159,7 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - private - - def update_state + def update_state! statuses.reload self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' @@ -173,11 +171,5 @@ module Ci self.duration = statuses.latest.duration save end - - def save_yaml_error(error) - return if self.yaml_errors? - self.yaml_errors = error - update_state - end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 5bc0c31cb4..0336b767de 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -8,7 +8,9 @@ module Ci return pipeline end - unless commit + if commit + pipeline.sha = commit.id + else pipeline.errors.add(:base, 'Commit not found') return pipeline end @@ -18,22 +20,18 @@ module Ci return pipeline end - begin - Ci::Commit.transaction do - pipeline.sha = commit.id - - unless pipeline.config_processor - pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') - raise ActiveRecord::Rollback - end - - pipeline.save! - pipeline.create_builds(current_user) - end - rescue - pipeline.errors.add(:base, 'The pipeline could not be created. Please try again.') + unless pipeline.config_processor + pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') + return pipeline end + pipeline.save! + + unless pipeline.create_builds(current_user) + pipeline.errors.add(:base, 'No builds for this pipeline.') + end + + pipeline.update_state! pipeline end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 5b6fefe669..ee84023e51 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -34,7 +34,7 @@ class CreateCommitBuildsService commit.create_builds(user) end - commit.touch + commit.update_state! commit end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 9ae8f31b37..e643991e0b 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -81,7 +81,7 @@ describe CreateCommitBuildsService, services: true do expect(commit.yaml_errors).not_to be_nil end - describe :ci_skip? do + context 'when commit contains a [ci skip] directive' do let(:message) { "some message[ci skip]" } before do From 1bcb61dd2076995b5fed786133f94def1fd637a5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 13:38:30 +0200 Subject: [PATCH 040/195] Add specs covering case when there are no builds --- .../create_commit_builds_service_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index e643991e0b..8c6b602ac8 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -171,5 +171,23 @@ describe CreateCommitBuildsService, services: true do expect(commit.status).to eq("failed") expect(commit.builds.any?).to be false end + + context 'when there are no jobs for this pipeline' do + before do + config = YAML.dump({ test: { deploy: 'ls', only: ['feature'] } }) + stub_ci_commit_yaml_file(config) + end + + it 'does not create a new pipeline' do + result = service.execute(project, user, + ref: 'refs/heads/master', + before: '00000000', + after: '31das312', + commits: [{ message: 'some msg'}]) + + expect(result).to be false + expect(Ci::Build.all).to be_empty + end + end end end From 0d19abf450d26fa76a23aaae38d392ecdef4e1e0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 13:52:19 +0200 Subject: [PATCH 041/195] Add minor improvements in create builds service --- app/models/ci/commit.rb | 2 +- app/services/ci/create_builds_service.rb | 9 ++------- lib/ci/gitlab_ci_yaml_processor.rb | 5 ++++- spec/services/create_commit_builds_service_spec.rb | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index c682f3e570..ccd26959ad 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -135,7 +135,7 @@ module Ci @config_processor ||= begin Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e - self.yaml_errors = (e.message) + self.yaml_errors = e.message nil rescue self.yaml_errors = 'Undefined error' diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index 18274ce24e..7fb2ad7e06 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -2,10 +2,11 @@ module Ci class CreateBuildsService def initialize(commit) @commit = commit + @config = commit.config_processor end def execute(stage, user, status, trigger_request = nil) - builds_attrs = config_processor.builds_for_stage_and_ref(stage, @commit.ref, @commit.tag, trigger_request) + builds_attrs = @config.builds_for_stage_and_ref(stage, @commit.ref, @commit.tag, trigger_request) # check when to create next build builds_attrs = builds_attrs.select do |build_attrs| @@ -41,11 +42,5 @@ module Ci end end end - - private - - def config_processor - @config_processor ||= @commit.config_processor - end end end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 026a5ac97c..fcc8af1648 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -27,7 +27,10 @@ module Ci end def builds_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil) - builds.select{|build| build[:stage] == stage && process?(build[:only], build[:except], ref, tag, trigger_request)} + builds.select do |build| + build[:stage] == stage && + process?(build[:only], build[:except], ref, tag, trigger_request) + end end def builds diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 8c6b602ac8..dc915e9dd7 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -183,7 +183,7 @@ describe CreateCommitBuildsService, services: true do ref: 'refs/heads/master', before: '00000000', after: '31das312', - commits: [{ message: 'some msg'}]) + commits: [{ message: 'some msg'} ]) expect(result).to be false expect(Ci::Build.all).to be_empty From 53fe06efde46acd2df62f818c421ecf3a0b971c9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 14:41:48 +0200 Subject: [PATCH 042/195] Update ci commit pipeline specs according to changes --- spec/models/ci/commit_spec.rb | 16 ++++++++-------- .../create_commit_builds_service_spec.rb | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 22f8639e5a..0939eb946a 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -346,9 +346,9 @@ describe Ci::Commit, models: true do end end - describe '#update_state' do - it 'execute update_state after touching object' do - expect(commit).to receive(:update_state).and_return(true) + describe '#update_state!' do + it 'execute update_state! after touching object' do + expect(commit).to receive(:update_state!).and_return(true) commit.touch end @@ -356,17 +356,17 @@ describe Ci::Commit, models: true do let(:commit_status) { build :commit_status, commit: commit } it 'execute update_state after saving dependent object' do - expect(commit).to receive(:update_state).and_return(true) + expect(commit).to receive(:update_state!).and_return(true) commit_status.save end end context 'update state' do let(:current) { Time.now.change(usec: 0) } - let(:build) { FactoryGirl.create :ci_build, :success, commit: commit, started_at: current - 120, finished_at: current - 60 } - - before do - build + let!(:build) do + create :ci_build, :success, commit: commit, + started_at: current - 120, + finished_at: current - 60 end [:status, :started_at, :finished_at, :duration].each do |param| diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index dc915e9dd7..b116e3e8fb 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -183,7 +183,7 @@ describe CreateCommitBuildsService, services: true do ref: 'refs/heads/master', before: '00000000', after: '31das312', - commits: [{ message: 'some msg'} ]) + commits: [{ message: 'some msg' }]) expect(result).to be false expect(Ci::Build.all).to be_empty From 07af37a243ea0d6b5741754ea116044ee46614b3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 15:55:38 +0200 Subject: [PATCH 043/195] Do not create pipeline objects when no builds --- app/services/ci/create_builds_service.rb | 41 +++++++++++-------- app/services/create_commit_builds_service.rb | 10 ++--- .../services/ci/create_builds_service_spec.rb | 6 ++- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index 7fb2ad7e06..a02b0b8f9b 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -20,26 +20,33 @@ module Ci end end + # don't create the same build twice + builds_attrs.reject! do |build_attrs| + @commit.builds.find_by(ref: @commit.ref, tag: @commit.tag, + trigger_request: trigger_request, + name: build_attrs[:name]) + end + builds_attrs.map do |build_attrs| - # don't create the same build twice - unless @commit.builds.find_by(ref: @commit.ref, tag: @commit.tag, - trigger_request: trigger_request, name: build_attrs[:name]) - build_attrs.slice!(:name, - :commands, - :tag_list, - :options, - :allow_failure, - :stage, - :stage_idx) + build_attrs.slice!(:name, + :commands, + :tag_list, + :options, + :allow_failure, + :stage, + :stage_idx) - build_attrs.merge!(ref: @commit.ref, - tag: @commit.tag, - trigger_request: trigger_request, - user: user, - project: @commit.project) + build_attrs.merge!(ref: @commit.ref, + tag: @commit.tag, + trigger_request: trigger_request, + user: user, + project: @commit.project) - @commit.builds.create!(build_attrs) - end + ## + # We do not persist new builds here. + # Those will be persisted when @commit is saved. + # + @commit.builds.new(build_attrs) end end end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index ee84023e51..3f048157b3 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -25,16 +25,16 @@ class CreateCommitBuildsService return false end - # Create a new ci_commit - commit.save! # Skip creating builds for commits that have [ci skip] unless commit.skip_ci? - # Create builds for commit - commit.create_builds(user) + # Create builds for commit and + # skip saving pipeline when there are no builds + return false unless commit.create_builds(user) end - commit.update_state! + # Create a new ci_commit + commit.save! commit end end diff --git a/spec/services/ci/create_builds_service_spec.rb b/spec/services/ci/create_builds_service_spec.rb index ecc3a88a26..8e737fd44f 100644 --- a/spec/services/ci/create_builds_service_spec.rb +++ b/spec/services/ci/create_builds_service_spec.rb @@ -9,7 +9,7 @@ describe Ci::CreateBuildsService, services: true do # subject do - described_class.new(commit).execute(commit, nil, user, status) + described_class.new(commit).execute('test', user, status, nil) end context 'next builds available' do @@ -17,6 +17,10 @@ describe Ci::CreateBuildsService, services: true do it { is_expected.to be_an_instance_of Array } it { is_expected.to all(be_an_instance_of Ci::Build) } + + it 'does not persist created builds' do + expect(subject.first).not_to be_persisted + end end context 'builds skipped' do From c6bce7e63c305d07dbc91d032df9c783e0cf0c9f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 17:17:23 +0200 Subject: [PATCH 044/195] Save Ci::Commit object to persist all created builds --- app/models/ci/build.rb | 5 ++++- app/models/ci/commit.rb | 6 ++++-- app/services/ci/create_pipeline_service.rb | 2 +- app/services/ci/create_trigger_request_service.rb | 1 + spec/models/ci/commit_spec.rb | 8 ++++++-- spec/requests/ci/api/builds_spec.rb | 5 +++++ 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 64723ab6b4..fd6fba42a3 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -66,7 +66,10 @@ module Ci # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed around_transition any => [:success, :failed, :canceled] do |build, block| block.call - build.commit.create_next_builds(build) if build.commit + if build.commit + build.commit.create_next_builds(build) + build.commit.save + end end after_transition any => [:success, :failed, :canceled] do |build| diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index ccd26959ad..7e6bb4f8c1 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -13,7 +13,7 @@ module Ci validate :valid_commit_sha # Invalidate object and save if when touched - after_touch :update_state! + after_touch :update_state def self.truncate_sha(sha) sha[0...8] @@ -159,7 +159,9 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - def update_state! + private + + def update_state statuses.reload self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 0336b767de..864415ef74 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -31,7 +31,7 @@ module Ci pipeline.errors.add(:base, 'No builds for this pipeline.') end - pipeline.update_state! + pipeline.save pipeline end diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 993acf11db..c611a96311 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -15,6 +15,7 @@ module Ci ) if ci_commit.create_builds(nil, trigger_request) + ci_commit.save trigger_request end end diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 0939eb946a..07b875e4f8 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -55,11 +55,15 @@ describe Ci::Commit, models: true do let!(:commit) { FactoryGirl.create :ci_commit, project: project, ref: 'master', tag: false } def create_builds(trigger_request = nil) - commit.create_builds(nil, trigger_request) + if commit.create_builds(nil, trigger_request) + commit.save + end end def create_next_builds - commit.create_next_builds(commit.builds.order(:id).last) + if commit.create_next_builds(commit.builds.order(:id).last) + commit.save + end end it 'creates builds' do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index e5124ea5ea..7eff804866 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -22,6 +22,7 @@ describe Ci::API::API do it "should start a build" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) + commit.save build = commit.builds.first post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -58,6 +59,7 @@ describe Ci::API::API do it "returns options" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) + commit.save post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -68,6 +70,7 @@ describe Ci::API::API do it "returns variables" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) + commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -87,6 +90,7 @@ describe Ci::API::API do trigger_request = FactoryGirl.create(:ci_trigger_request_with_variables, commit: commit, trigger: trigger) commit.create_builds(nil, trigger_request) + commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -105,6 +109,7 @@ describe Ci::API::API do it "returns dependent builds" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil, nil) + commit.save commit.builds.where(stage: 'test').each(&:success) post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } From 8e811f2c6c4c74a30789ff5213de5ebc28897753 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 21:02:52 +0200 Subject: [PATCH 045/195] Update CreateCommitBuildsService to pass tests --- app/services/ci/create_builds_service.rb | 3 +- app/services/create_commit_builds_service.rb | 35 ++++++++++--------- spec/models/ci/commit_spec.rb | 6 ++-- .../create_commit_builds_service_spec.rb | 8 ++--- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index a02b0b8f9b..f458dee49a 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -36,7 +36,8 @@ module Ci :stage, :stage_idx) - build_attrs.merge!(ref: @commit.ref, + build_attrs.merge!(commit: @commit, + ref: @commit.ref, tag: @commit.tag, trigger_request: trigger_request, user: user, diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 3f048157b3..aa0ec45be8 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -1,40 +1,41 @@ class CreateCommitBuildsService def execute(project, user, params) - return false unless project.builds_enabled? + return unless project.builds_enabled? before_sha = params[:checkout_sha] || params[:before] sha = params[:checkout_sha] || params[:after] origin_ref = params[:ref] - unless origin_ref && sha.present? - return false - end - ref = Gitlab::Git.ref_name(origin_ref) tag = Gitlab::Git.tag_ref?(origin_ref) - # Skip branch removal - if sha == Gitlab::Git::BLANK_SHA - return false - end - commit = Ci::Commit.new(project: project, sha: sha, ref: ref, before_sha: before_sha, tag: tag) - # Skip creating ci_commit when no gitlab-ci.yml is found unless commit.ci_yaml_file - return false + commit.errors.add(:base, 'No .gitlab-ci.yml file found') + return commit end + # Make object as skipped + if commit.skip_ci? + commit.status = 'skipped' + commit.save + return commit + end - # Skip creating builds for commits that have [ci skip] - unless commit.skip_ci? - # Create builds for commit and - # skip saving pipeline when there are no builds - return false unless commit.create_builds(user) + # Create builds for commit and + # skip saving pipeline when there are no builds + unless commit.create_builds(user) + # Save object when there are yaml errors + unless commit.yaml_errors.present? + commit.errors.add(:base, 'No builds created') + return commit + end end # Create a new ci_commit commit.save! + commit.touch commit end end diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 07b875e4f8..d36aca113a 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -351,8 +351,8 @@ describe Ci::Commit, models: true do end describe '#update_state!' do - it 'execute update_state! after touching object' do - expect(commit).to receive(:update_state!).and_return(true) + it 'execute update_state after touching object' do + expect(commit).to receive(:update_state).and_return(true) commit.touch end @@ -360,7 +360,7 @@ describe Ci::Commit, models: true do let(:commit_status) { build :commit_status, commit: commit } it 'execute update_state after saving dependent object' do - expect(commit).to receive(:update_state!).and_return(true) + expect(commit).to receive(:update_state).and_return(true) commit_status.save end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index b116e3e8fb..e3202c959d 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -60,7 +60,7 @@ describe CreateCommitBuildsService, services: true do after: '31das312', commits: [{ message: 'Message' }] ) - expect(result).to be_falsey + expect(result).not_to be_persisted expect(Ci::Commit.count).to eq(0) end @@ -174,7 +174,7 @@ describe CreateCommitBuildsService, services: true do context 'when there are no jobs for this pipeline' do before do - config = YAML.dump({ test: { deploy: 'ls', only: ['feature'] } }) + config = YAML.dump({ test: { script: 'ls', only: ['feature'] } }) stub_ci_commit_yaml_file(config) end @@ -184,9 +184,9 @@ describe CreateCommitBuildsService, services: true do before: '00000000', after: '31das312', commits: [{ message: 'some msg' }]) - - expect(result).to be false + expect(result).not_to be_persisted expect(Ci::Build.all).to be_empty + expect(Ci::Commit.count).to eq(0) end end end From 681b472c36d5079b620b93957d62dbacc473bf6f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 21:04:45 +0200 Subject: [PATCH 046/195] Update specs describe --- 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 d36aca113a..a4549d4046 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -350,7 +350,7 @@ describe Ci::Commit, models: true do end end - describe '#update_state!' do + describe '#update_state' do it 'execute update_state after touching object' do expect(commit).to receive(:update_state).and_return(true) commit.touch From a21d084ded6cdf5b83163d4d72bb5c636218d091 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 3 Jun 2016 10:01:00 +0200 Subject: [PATCH 047/195] Fix specs for pipeline create for merge requests --- spec/features/merge_requests/created_from_fork_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index edc0bdec3d..16c572b319 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -34,7 +34,10 @@ feature 'Merge request created from fork' do ref: merge_request.source_branch) end - background { pipeline.create_builds(user) } + background do + pipeline.create_builds(user) + pipeline.save + end scenario 'user visits a pipelines page', js: true do visit_merge_request(merge_request) From d8c4556d3c8623bf48e689f3734c9c35cda34c2f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 3 Jun 2016 11:58:08 +0200 Subject: [PATCH 048/195] Refactor code reponsible for creating builds This removes duplications and extracts method that builds build-jobs without persisting those objects, to a separate method. --- app/models/ci/build.rb | 5 +---- app/models/ci/commit.rb | 19 +++++++++++++++---- .../ci/create_trigger_request_service.rb | 1 - app/services/create_commit_builds_service.rb | 2 +- .../merge_requests/created_from_fork_spec.rb | 5 +---- spec/models/ci/commit_spec.rb | 8 ++------ spec/requests/ci/api/builds_spec.rb | 5 ----- 7 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index fd6fba42a3..64723ab6b4 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -66,10 +66,7 @@ module Ci # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed around_transition any => [:success, :failed, :canceled] do |build, block| block.call - if build.commit - build.commit.create_next_builds(build) - build.commit.save - end + build.commit.create_next_builds(build) if build.commit end after_transition any => [:success, :failed, :canceled] do |build| diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index 7e6bb4f8c1..cdab6d5f31 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -89,13 +89,22 @@ module Ci trigger_requests.any? end - def create_builds(user, trigger_request = nil) + def build_builds_for_stage(stage, user, status, trigger_request) + CreateBuildsService.new(self).execute(stage, user, status, trigger_request) + end + + def build_builds(user, status = 'success', trigger_request = nil) return unless config_processor config_processor.stages.any? do |stage| - CreateBuildsService.new(self).execute(stage, user, 'success', trigger_request).present? + build_builds_for_stage(stage, user, status, trigger_request).present? end end + def create_builds(user, trigger_request = nil) + build_builds(user, 'success', trigger_request) + save! + end + def create_next_builds(build) return unless config_processor @@ -112,9 +121,11 @@ module Ci prior_status = prior_builds.status # create builds for next stages based - next_stages.any? do |stage| - CreateBuildsService.new(self).execute(stage, build.user, prior_status, build.trigger_request).present? + have_builds = next_stages.any? do |stage| + build_builds_for_stage(stage, build.user, prior_status, build.trigger_request).present? end + + save! if have_builds end def retried diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index c611a96311..993acf11db 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -15,7 +15,6 @@ module Ci ) if ci_commit.create_builds(nil, trigger_request) - ci_commit.save trigger_request end end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index aa0ec45be8..dbfc93ff5b 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -25,7 +25,7 @@ class CreateCommitBuildsService # Create builds for commit and # skip saving pipeline when there are no builds - unless commit.create_builds(user) + unless commit.build_builds(user) # Save object when there are yaml errors unless commit.yaml_errors.present? commit.errors.add(:base, 'No builds created') diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index 16c572b319..edc0bdec3d 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -34,10 +34,7 @@ feature 'Merge request created from fork' do ref: merge_request.source_branch) end - background do - pipeline.create_builds(user) - pipeline.save - end + background { pipeline.create_builds(user) } scenario 'user visits a pipelines page', js: true do visit_merge_request(merge_request) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index a4549d4046..01d931b087 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -55,15 +55,11 @@ describe Ci::Commit, models: true do let!(:commit) { FactoryGirl.create :ci_commit, project: project, ref: 'master', tag: false } def create_builds(trigger_request = nil) - if commit.create_builds(nil, trigger_request) - commit.save - end + commit.create_builds(nil, trigger_request) end def create_next_builds - if commit.create_next_builds(commit.builds.order(:id).last) - commit.save - end + commit.create_next_builds(commit.builds.order(:id).last) end it 'creates builds' do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index 7eff804866..e5124ea5ea 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -22,7 +22,6 @@ describe Ci::API::API do it "should start a build" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) - commit.save build = commit.builds.first post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -59,7 +58,6 @@ describe Ci::API::API do it "returns options" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) - commit.save post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -70,7 +68,6 @@ describe Ci::API::API do it "returns variables" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) - commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -90,7 +87,6 @@ describe Ci::API::API do trigger_request = FactoryGirl.create(:ci_trigger_request_with_variables, commit: commit, trigger: trigger) commit.create_builds(nil, trigger_request) - commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -109,7 +105,6 @@ describe Ci::API::API do it "returns dependent builds" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil, nil) - commit.save commit.builds.where(stage: 'test').each(&:success) post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } From 3b21174d32695d10124bd4d582db14947bf4162d Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Mon, 6 Jun 2016 15:50:58 +0200 Subject: [PATCH 049/195] Check if the Users table has exactly one user limiting the whole set --- app/controllers/sessions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f6eedb1773..fd57478fc9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -39,7 +39,7 @@ class SessionsController < Devise::SessionsController # Handle an "initial setup" state, where there's only one user, it's an admin, # and they require a password change. def check_initial_setup - return unless User.count == 1 + return unless User.limit(2).count == 1 # Count as much 2 to know if we have exactly one user = User.admins.last From 8aad78838374c761a69d7f0e9727706a611ebcaf Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:28 +0300 Subject: [PATCH 050/195] Added data-project attribute to body tag. --- app/views/layouts/application.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 2b86b289bb..504abd8f3e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: "en"} = render "layouts/head" - %body{class: "#{user_application_theme}", 'data-page' => body_data_page} + %body{class: "#{user_application_theme}", 'data-page' => body_data_page, 'data-project' => "#{@project.path if @project}"} = Gon::Base.render_data -# Ideally this would be inside the head, but turbolinks only evaluates page-specific JS in the body. From b13f6fa99dace85844db2591c922fe7158e7baac Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:47 +0300 Subject: [PATCH 051/195] Added a common util to get project slug. --- app/assets/javascripts/lib/common_utils.js.coffee | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/assets/javascripts/lib/common_utils.js.coffee diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee new file mode 100644 index 0000000000..3ec569f73e --- /dev/null +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -0,0 +1,13 @@ +((w) -> + + w.gl or= {} + w.gl.utils or= {} + + w.gl.utils.getProjectSlug = -> + + $body = $ 'body' + isInProjectPage = $body.data('page').split(':')[0] is 'projects' + + return if isInProjectPage then $body.data 'project' else null + +) window From 5b64e486cceda778161ee99da6f60a06c3ba4d08 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:11:23 +0300 Subject: [PATCH 052/195] Added projectOptions and dashboardOptions into gl object. --- app/views/layouts/_search.html.haml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index b49207fc31..b76e31f7dc 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -36,6 +36,21 @@ - else = hidden_field_tag :search_code, true + :javascript + gl.projectOptions = gl.projectOptions || {}; + gl.projectOptions["#{@project.path}"] = { + issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", + mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", + projectName: "#{@project.name}" + }; + + :javascript + gl.dashboardOptions = { + issuesPath: "#{issues_dashboard_url}", + mrPath: "#{merge_requests_dashboard_url}" + }; + + - if @snippet || @snippets = hidden_field_tag :snippets, true = hidden_field_tag :repository_ref, @ref From 495d27be382266513f159a5babc334a93b540a95 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:13:02 +0300 Subject: [PATCH 053/195] Show category search content in the search dropdown. --- .../javascripts/search_autocomplete.js.coffee | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 5eb915a51e..0ba2c4958a 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -67,8 +67,14 @@ class @SearchAutocomplete getData: (term, callback) -> _this = @ - # Do not trigger request if input is empty - return if @searchInput.val() is '' + unless term + return unless @hasLocationBadge() + + if contents = @getCategoryContents() + @searchInput.data('glDropdown').filter.options.callback contents + @enableAutocomplete() + + return # Prevent multiple ajax calls return if @loadingSuggestions @@ -122,6 +128,27 @@ class @SearchAutocomplete ).always -> _this.loadingSuggestions = false + + getCategoryContents: -> + + userId = gon.current_user_id + projectSlug = gl.utils.getProjectSlug() + projectOptions = gl.projectOptions[projectSlug] + + return null if not projectSlug or not projectOptions + + { issuesPath, mrPath, projectName } = projectOptions + + return [ + { header: "Go to in #{projectName}" } + { text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" } + { text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" } + 'separator' + { text: 'Merge requests assigned to me', url: "#{mrPath}/?assignee_id=#{userId}" } + { text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" } + ] + + serializeState: -> { # Search Criteria @@ -209,6 +236,13 @@ class @SearchAutocomplete @isFocused = true @wrap.addClass('search-active') + if @hasLocationBadge() and @getValue() is '' + @getData() + + + getValue: -> return @searchInput.val() + + onClearInputClick: (e) => e.preventDefault() @searchInput.val('').focus() @@ -229,6 +263,10 @@ class @SearchAutocomplete @locationBadgeEl.text(badgeText).show() @wrap.addClass('has-location-badge') + + hasLocationBadge: -> return @wrap.is '.has-location-badge' + + restoreOriginalState: -> inputs = Object.keys @originalState @@ -257,13 +295,14 @@ class @SearchAutocomplete @getElement("##{input}").val('') + removeLocationBadge: -> + @locationBadgeEl.hide() - - # Reset state @resetSearchState() - @wrap.removeClass('has-location-badge') + @disableAutocomplete() + disableAutocomplete: -> @searchInput.addClass('disabled') From 36f67b305f37cdf4eb9f75f12cfde3b0dfc01183 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 14:49:25 +0300 Subject: [PATCH 054/195] Show dashboard related options in the search dropdown. --- app/assets/javascripts/search_autocomplete.js.coffee | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 0ba2c4958a..943dba9bcb 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -132,12 +132,14 @@ class @SearchAutocomplete getCategoryContents: -> userId = gon.current_user_id + projectName = 'Dashboard' projectSlug = gl.utils.getProjectSlug() projectOptions = gl.projectOptions[projectSlug] - return null if not projectSlug or not projectOptions - - { issuesPath, mrPath, projectName } = projectOptions + if projectSlug and projectOptions + { issuesPath, mrPath, projectName } = projectOptions + else + { issuesPath, mrPath } = gl.dashboardOptions return [ { header: "Go to in #{projectName}" } From 50b3b8ce80b3573f53c22ac5ff34391b5bc469d8 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 17:54:29 +0300 Subject: [PATCH 055/195] Added tests for categorised search autocomplete. --- .../javascripts/search_autocomplete.js.coffee | 2 +- spec/features/search_spec.rb | 79 +++++++++++ .../fixtures/search_autocomplete.html.haml | 10 ++ spec/javascripts/notes_spec.js.coffee | 2 +- spec/javascripts/project_title_spec.js.coffee | 2 +- .../search_autocomplete_spec.js.coffee | 129 ++++++++++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 spec/javascripts/fixtures/search_autocomplete.html.haml create mode 100644 spec/javascripts/search_autocomplete_spec.js.coffee diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 943dba9bcb..8493d2684d 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -134,7 +134,7 @@ class @SearchAutocomplete userId = gon.current_user_id projectName = 'Dashboard' projectSlug = gl.utils.getProjectSlug() - projectOptions = gl.projectOptions[projectSlug] + projectOptions = gl.projectOptions?[projectSlug] if projectSlug and projectOptions { issuesPath, mrPath, projectName } = projectOptions diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 029a11ea43..4f4d4b1e3e 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -47,4 +47,83 @@ describe "Search", feature: true do expect(page).to have_link(snippet.title) end end + + + describe 'Right header search field', feature: true do + + describe 'Search in project page' do + before do + visit namespace_project_path(project.namespace, project) + end + + it 'top right search form is present' do + expect(page).to have_selector('#search') + end + + it 'top right search form contains location badge' do + expect(page).to have_selector('.has-location-badge') + end + + context 'clicking the search field', js: true do + it 'should show category search dropdown' do + page.find('#search').click + + expect(page).to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + end + end + + context 'click the links in the category search dropdown', js: true do + + before do + page.find('#search').click + end + + it 'should take user to her issues page when issues assigned is clicked' do + find('.dropdown-menu').click_link 'Issues assigned to me' + sleep 2 + + expect(page).to have_selector('.issues-holder') + expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) + end + + it 'should take user to her issues page when issues authored is clicked' do + find('.dropdown-menu').click_link "Issues I've created" + sleep 2 + + expect(page).to have_selector('.issues-holder') + expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) + end + + it 'should take user to her MR page when MR assigned is clicked' do + find('.dropdown-menu').click_link 'Merge requests assigned to me' + sleep 2 + + expect(page).to have_selector('.merge-requests-holder') + expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) + end + + it 'should take user to her MR page when MR authored is clicked' do + find('.dropdown-menu').click_link "Merge requests I've created" + sleep 2 + + expect(page).to have_selector('.merge-requests-holder') + expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) + end + end + + context 'entering text into the search field', js: true do + before do + page.within '.search-input-wrap' do + fill_in "search", with: project.name[0..3] + end + end + + it 'should not display the category search dropdown' do + expect(page).not_to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + end + end + end + end + + end diff --git a/spec/javascripts/fixtures/search_autocomplete.html.haml b/spec/javascripts/fixtures/search_autocomplete.html.haml new file mode 100644 index 0000000000..7785120da5 --- /dev/null +++ b/spec/javascripts/fixtures/search_autocomplete.html.haml @@ -0,0 +1,10 @@ +.search.search-form.has-location-badge + %form.navbar-form + .search-input-container + %div.location-badge + This project + .search-input-wrap + .dropdown + %input#search.search-input.dropdown-menu-toggle + .dropdown-menu.dropdown-select + .dropdown-content diff --git a/spec/javascripts/notes_spec.js.coffee b/spec/javascripts/notes_spec.js.coffee index dd160e821b..3a3c8d63e8 100644 --- a/spec/javascripts/notes_spec.js.coffee +++ b/spec/javascripts/notes_spec.js.coffee @@ -1,7 +1,7 @@ #= require notes #= require gl_form -window.gon = {} +window.gon or= {} window.disableButtonIfEmptyField = -> null describe 'Notes', -> diff --git a/spec/javascripts/project_title_spec.js.coffee b/spec/javascripts/project_title_spec.js.coffee index 1cf34d4d2d..9be29097f4 100644 --- a/spec/javascripts/project_title_spec.js.coffee +++ b/spec/javascripts/project_title_spec.js.coffee @@ -6,7 +6,7 @@ #= require project_select #= require project -window.gon = {} +window.gon or= {} window.gon.api_version = 'v3' describe 'Project Title', -> diff --git a/spec/javascripts/search_autocomplete_spec.js.coffee b/spec/javascripts/search_autocomplete_spec.js.coffee new file mode 100644 index 0000000000..5212f5d223 --- /dev/null +++ b/spec/javascripts/search_autocomplete_spec.js.coffee @@ -0,0 +1,129 @@ +#= require gl_dropdown +#= require search_autocomplete +#= require jquery +#= require lib/common_utils +#= require lib/type_utility +#= require fuzzaldrin-plus + + +widget = null +userId = 1 +window.gon or= {} +window.gon.current_user_id = userId + +dashboardIssuesPath = '/dashboard/issues' +dashboardMRsPath = '/dashboard/merge_requests' +projectIssuesPath = "/gitlab-org/gitlab-ce/issues" +projectMRsPath = "/gitlab-org/gitlab-ce/merge_requests" +projectName = 'GitLab Community Edition' + +# Add required attributes to body before starting the test. +addBodyAttributes = (page = 'groups') -> + + $('body').removeAttr 'data-page' + $('body').removeAttr 'data-project' + + $('body').data 'page', "#{page}:show" + $('body').data 'project', 'gitlab-ce' + + +# Mock `gl` object in window for dashboard specific page. App code will need it. +mockDashboardOptions = -> + + window.gl or= {} + window.gl.dashboardOptions = + issuesPath: dashboardIssuesPath + mrPath : dashboardMRsPath + + +# Mock `gl` object in window for project specific page. App code will need it. +mockProjectOptions = -> + + window.gl or= {} + window.gl.projectOptions = + 'gitlab-ce' : + issuesPath : projectIssuesPath + mrPath : projectMRsPath + projectName : projectName + + +assertLinks = (list, a1, a2, a3, a4) -> + + expect(list.find(a1).length).toBe 1 + expect(list.find(a1).text()).toBe ' Issues assigned to me ' + + expect(list.find(a2).length).toBe 1 + expect(list.find(a2).text()).toBe " Issues I've created " + + expect(list.find(a3).length).toBe 1 + expect(list.find(a3).text()).toBe ' Merge requests assigned to me ' + + expect(list.find(a4).length).toBe 1 + expect(list.find(a4).text()).toBe " Merge requests I've created " + + + +describe 'Search autocomplete dropdown', -> + + fixture.preload 'search_autocomplete.html' + + beforeEach -> + + fixture.load 'search_autocomplete.html' + widget = new SearchAutocomplete + + + it 'should show Dashboard specific dropdown menu', -> + + addBodyAttributes() + mockDashboardOptions() + + # Focus input to show dropdown list. + widget.searchInput.focus() + + w = widget.wrap.find '.dropdown-menu' + l = w.find 'ul' + + # # Expect dropdown and dropdown header + expect(w.find('.dropdown-header').text()).toBe 'Go to in Dashboard' + + # Create links then assert link urls and inner texts + issuesAssignedToMeLink = "#{dashboardIssuesPath}/?assignee_id=#{userId}" + issuesIHaveCreatedLink = "#{dashboardIssuesPath}/?author_id=#{userId}" + mrsAssignedToMeLink = "#{dashboardMRsPath}/?assignee_id=#{userId}" + mrsIHaveCreatedLink = "#{dashboardMRsPath}/?author_id=#{userId}" + + a1 = "a[href='#{issuesAssignedToMeLink}']" + a2 = "a[href='#{issuesIHaveCreatedLink}']" + a3 = "a[href='#{mrsAssignedToMeLink}']" + a4 = "a[href='#{mrsIHaveCreatedLink}']" + + assertLinks l, a1, a2, a3, a4 + + + it 'should show Project specific dropdown menu', -> + + addBodyAttributes 'projects' + mockProjectOptions() + + # Focus input to show dropdown list. + widget.searchInput.focus() + + w = widget.wrap.find '.dropdown-menu' + l = w.find 'ul' + + # Expect dropdown and dropdown header + expect(w.find('.dropdown-header').text()).toBe "Go to in #{projectName}" + + # Create links then verify link urls and inner texts + issuesAssignedToMeLink = "#{projectIssuesPath}/?assignee_id=#{userId}" + issuesIHaveCreatedLink = "#{projectIssuesPath}/?author_id=#{userId}" + mrsAssignedToMeLink = "#{projectMRsPath}/?assignee_id=#{userId}" + mrsIHaveCreatedLink = "#{projectMRsPath}/?author_id=#{userId}" + + a1 = "a[href='#{issuesAssignedToMeLink}']" + a2 = "a[href='#{issuesIHaveCreatedLink}']" + a3 = "a[href='#{mrsAssignedToMeLink}']" + a4 = "a[href='#{mrsIHaveCreatedLink}']" + + assertLinks l, a1, a2, a3, a4 From 8827eea8643bba95571edf2ea0f769b18e8369c2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 08:28:18 +0100 Subject: [PATCH 056/195] Updated commits UI Closes #14633 --- app/assets/stylesheets/pages/commits.scss | 124 +++++++++--------- app/helpers/ci_status_helper.rb | 8 +- app/helpers/commits_helper.rb | 26 +++- app/views/projects/commits/_commit.html.haml | 21 ++- app/views/projects/commits/_commits.html.haml | 17 +-- app/views/projects/commits/show.html.haml | 11 +- .../merge_requests/show/_commits.html.haml | 3 +- 7 files changed, 109 insertions(+), 101 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c8c6bbde08..05949e2cd4 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -7,72 +7,82 @@ margin-right: 9px; } -.lists-separator { - margin: 10px 0; - border-color: #ddd; -} +.commit-header { + padding: 5px 10px; + background-color: $background-color; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + font-size: 14px; -.commits-row { - ul { - margin: 0; - - li.commit { - padding: 8px 0; - } - } - - .commits-row-date { - font-size: 15px; - line-height: 20px; - margin-bottom: 5px; + &:first-child { + border-top-width: 0; } } -li.commit { - list-style: none; +.commit-row-title { + line-height: 20px; + margin-bottom: 2px; - .commit-row-title { - font-size: $list-font-size; - line-height: 20px; - margin-bottom: 2px; + .notes_count { + float: right; + margin-right: 10px; + } - .btn-clipboard { - margin-top: -1px; + .str-truncated { + max-width: 70%; + } + + .commit-row-message { + color: $gl-dark-link-color; + + &:hover { + text-decoration: underline; } + } - .notes_count { - float: right; - margin-right: 10px; + .text-expander { + background: #eee; + color: #555; + padding: 0 5px; + cursor: pointer; + margin-left: 4px; + &:hover { + background-color: #ddd; } + } +} - .commit_short_id { - min-width: 65px; - color: $gl-dark-link-color; - font-family: $monospace_font; +.commit-actions { + @media (min-width: $screen-md-min) { + float: right; + } +} + +.commit-short-id { + font-family: $monospace_font; + font-weight: 600; +} + +.commit { + padding: 10px 0 10px 55px; + + &:not(:last-child) { + border-bottom: 1px solid #eee; + } + + a { + color: $gl-dark-link-color; + } + + .commit-link { + &:hover { + color: $gl-link-color; + text-decoration: none; } + } - .str-truncated { - max-width: 70%; - } - - .commit-row-message { - color: $gl-dark-link-color; - - &:hover { - text-decoration: underline; - } - } - - .text-expander { - background: #eee; - color: #555; - padding: 0 5px; - cursor: pointer; - margin-left: 4px; - &:hover { - background-color: #ddd; - } - } + .avatar { + margin-left: -55px; } .item-title { @@ -84,7 +94,7 @@ li.commit { font-size: 14px; border-left: 1px solid #eee; padding: 10px 15px; - margin: 5px 0 10px 5px; + margin: 10px 0 10px 0; background: #f9f9f9; display: none; @@ -111,10 +121,6 @@ li.commit { .avatar { margin-right: 8px; } - - .committed_ago { - display: inline-block; - } } &.inline-commit { diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 07e5c14684..8e4ae1e6ae 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -38,10 +38,10 @@ module CiStatusHelper icon(icon_name + ' fw') end - def render_commit_status(commit, tooltip_placement: 'auto left') + def render_commit_status(commit, tooltip_placement: 'auto left', cssclass: '') project = commit.project path = builds_namespace_project_commit_path(project.namespace, project, commit) - render_status_with_link('commit', commit.status, path, tooltip_placement) + render_status_with_link('commit', commit.status, path, tooltip_placement, cssclass: cssclass) end def render_pipeline_status(pipeline, tooltip_placement: 'auto left') @@ -57,10 +57,10 @@ module CiStatusHelper private - def render_status_with_link(type, status, path, tooltip_placement) + def render_status_with_link(type, status, path, tooltip_placement, cssclass: '') link_to ci_icon_for_status(status), path, - class: "ci-status-link ci-status-icon-#{status.dasherize}", + class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}", title: "#{type.titleize}: #{ci_label_for_status(status)}", data: { toggle: 'tooltip', placement: tooltip_placement } end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d328f56c80..767b346f2f 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -16,6 +16,19 @@ module CommitsHelper commit_person_link(commit, options.merge(source: :committer)) end + def commit_author_avatar(commit, options = {}) + options = options.merge(source: :author) + user = commit.send(options[:source]) + + source_name = clean(commit.send "#{options[:source]}_name".to_sym) + source_email = clean(commit.send "#{options[:source]}_email".to_sym) + + person_name = user.try(:name) || source_name + person_email = user.try(:email) || source_email + + image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") + end + def image_diff_class(diff) if diff.deleted_file "deleted" @@ -102,24 +115,24 @@ module CommitsHelper if current_controller?(:projects, :commits) if @repo.blob_at(commit.id, @path) return link_to( - "Browse File »", + "Browse File", namespace_project_blob_path(project.namespace, project, tree_join(commit.id, @path)), - class: "pull-right" + class: "btn btn-default" ) elsif @path.present? return link_to( - "Browse Directory »", + "Browse Directory", namespace_project_tree_path(project.namespace, project, tree_join(commit.id, @path)), - class: "pull-right" + class: "btn btn-default" ) end end link_to( "Browse Files", namespace_project_tree_path(project.namespace, project, commit), - class: "pull-right" + class: "btn btn-default" ) end @@ -191,8 +204,7 @@ module CommitsHelper text = if options[:avatar] - avatar = image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") - %Q{#{avatar} #{person_name}} + %Q{#{person_name}} else person_name end diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 367027182b..288b95c3e6 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,26 +9,25 @@ = cache(cache_key) do %li.commit.js-toggle-container{ id: "commit-#{commit.short_id}" } + = commit_author_avatar(commit) .commit-row-title %span.item-title - = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" + = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message commit-link" - if commit.description? %a.text-expander.js-toggle-button ... - .pull-right + .commit-actions - if commit.status - = render_commit_status(commit) + = render_commit_status(commit, cssclass: 'btn btn-transparent') = clipboard_button(clipboard_text: commit.id) - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent commit-link" + = link_to_browse_code(project, commit) - if commit.description? - .commit-row-description.js-toggle-content - %pre - = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) + %pre.commit-row-description.js-toggle-content + = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) .commit-row-info - by = commit_author_link(commit, avatar: true, size: 24) - .committed_ago - #{time_ago_with_tooltip(commit.committed_date)}   - = link_to_browse_code(project, commit) + authored + #{time_ago_with_tooltip(commit.committed_date)}   diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 7283a78a64..dd12eae8f7 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -4,18 +4,11 @@ - commits, hidden = limited_commits(@commits) - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits| - .row.commits-row - .col-md-2.hidden-xs.hidden-sm - %h5.commits-row-date - %i.fa.fa-calendar - %span= day.strftime('%d %b, %Y') - .light - = pluralize(commits.count, 'commit') - .col-md-10.col-sm-12 - %ul.content-list - = render commits, project: project - %hr.lists-separator + %li.commit-header= "#{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')}" + %li.commits-row + %ul.list-unstyled.commit-list + = render commits, project: project - if hidden > 0 - .alert.alert-warning + %li.alert.alert-warning #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 76ba0bea36..51ca4eb903 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -23,21 +23,18 @@ Create Merge Request .control - = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'pull-left commits-search-form') do - = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input', spellcheck: false } - + = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do + = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } - if current_user && current_user.private_token .control = link_to namespace_project_commits_path(@project.namespace, @project, @ref, {format: :atom, private_token: current_user.private_token}), title: "Commits Feed", class: 'btn' do = icon("rss") - - %ul.breadcrumb.repo-breadcrumb = commits_breadcrumbs %div{id: dom_id(@project)} - #commits-list.content_list= render "commits", project: @project - .clear + %ol#commits-list.list-unstyled.content_list + = render "commits", project: @project = spinner :javascript diff --git a/app/views/projects/merge_requests/show/_commits.html.haml b/app/views/projects/merge_requests/show/_commits.html.haml index a8f09f855d..0b05785430 100644 --- a/app/views/projects/merge_requests/show/_commits.html.haml +++ b/app/views/projects/merge_requests/show/_commits.html.haml @@ -2,4 +2,5 @@ = icon("sort-amount-desc") Most recent commits displayed first -= render "projects/commits/commits", project: @merge_request.project +%ol#commits-list.list-unstyled + = render "projects/commits/commits", project: @merge_request.project From 79b375e17876105cefcbc5c451e785aceedb0002 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 14:00:49 +0100 Subject: [PATCH 057/195] Updated some commit UI colors Fixed issue with tree view styles --- app/assets/stylesheets/pages/commits.scss | 25 ++++++++++---------- app/assets/stylesheets/pages/tree.scss | 2 +- app/helpers/button_helper.rb | 4 ++-- app/views/projects/commits/_commit.html.haml | 8 +++---- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 05949e2cd4..d360a22484 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -41,13 +41,17 @@ } .text-expander { - background: #eee; - color: #555; + background: $gray-light; + color: $gl-gray-dark; padding: 0 5px; cursor: pointer; - margin-left: 4px; + border: 1px solid $border-gray-dark; + border-radius: $border-radius-default; + margin-left: 5px; + &:hover { - background-color: #ddd; + background-color: darken($gray-light, 10%); + text-decoration: none; } } } @@ -55,6 +59,7 @@ .commit-actions { @media (min-width: $screen-md-min) { float: right; + margin-left: $gl-padding; } } @@ -70,17 +75,11 @@ border-bottom: 1px solid #eee; } - a { + a, + button { color: $gl-dark-link-color; } - .commit-link { - &:hover { - color: $gl-link-color; - text-decoration: none; - } - } - .avatar { margin-left: -55px; } @@ -94,7 +93,7 @@ font-size: 14px; border-left: 1px solid #eee; padding: 10px 15px; - margin: 10px 0 10px 0; + margin: 10px 0; background: #f9f9f9; display: none; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index f16fc7f388..cfb6e2e888 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -101,7 +101,7 @@ margin: 0; .commit { - padding: 0; + padding: 0 0 0 55px; .commit-row-title { .commit-row-message { diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index f742922d92..bf5505125a 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -14,10 +14,10 @@ module ButtonHelper # # => "" # # See http://clipboardjs.com/#usage - def clipboard_button(data = {}) + def clipboard_button(data = {}, css_class: 'btn-clipboard') content_tag :button, icon('clipboard'), - class: 'btn btn-clipboard', + class: "btn #{css_class}", data: data, type: :button end diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 288b95c3e6..f79c9448f6 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -12,15 +12,15 @@ = commit_author_avatar(commit) .commit-row-title %span.item-title - = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message commit-link" + = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" - if commit.description? %a.text-expander.js-toggle-button ... .commit-actions - if commit.status = render_commit_status(commit, cssclass: 'btn btn-transparent') - = clipboard_button(clipboard_text: commit.id) - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent commit-link" + = clipboard_button({ clipboard_text: commit.id }, css_class: 'btn-transparent') + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" = link_to_browse_code(project, commit) - if commit.description? @@ -30,4 +30,4 @@ .commit-row-info = commit_author_link(commit, avatar: true, size: 24) authored - #{time_ago_with_tooltip(commit.committed_date)}   + #{time_ago_with_tooltip(commit.committed_date)} From 8b40a7745be84659c10db02e3bbb74126bd42414 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 17:06:49 +0100 Subject: [PATCH 058/195] Updated tests --- app/helpers/button_helper.rb | 8 ++++++++ app/helpers/commits_helper.rb | 1 - app/views/projects/commits/_commit.html.haml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index bf5505125a..fabd726aae 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -22,6 +22,14 @@ module ButtonHelper type: :button end + def clipboard_button_with_class(data = {}, css_class: 'btn-clipboard') + content_tag :button, + icon('clipboard'), + class: "btn #{css_class}", + data: data, + type: :button + end + def http_clone_button(project) klass = 'http-selector' klass << ' has-tooltip' if current_user.try(:require_password?) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 767b346f2f..97d52b1fb9 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -200,7 +200,6 @@ module CommitsHelper source_email = clean(commit.send "#{options[:source]}_email".to_sym) person_name = user.try(:name) || source_name - person_email = user.try(:email) || source_email text = if options[:avatar] diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index f79c9448f6..d6661deb5f 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -19,7 +19,7 @@ .commit-actions - if commit.status = render_commit_status(commit, cssclass: 'btn btn-transparent') - = clipboard_button({ clipboard_text: commit.id }, css_class: 'btn-transparent') + = clipboard_button_with_class({ clipboard_text: commit.id }, css_class: 'btn-transparent') = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" = link_to_browse_code(project, commit) From 48726e9d307536318c7d87e2ba93f93582e22bfa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 25 May 2016 09:11:47 +0100 Subject: [PATCH 059/195] Updated failing tests --- app/assets/stylesheets/pages/commits.scss | 4 ++-- app/helpers/button_helper.rb | 4 ++-- app/helpers/commits_helper.rb | 3 --- features/steps/project/source/browse_files.rb | 6 +++--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index d360a22484..ba8d9cce49 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -10,8 +10,8 @@ .commit-header { padding: 5px 10px; background-color: $background-color; - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; + border-bottom: 1px solid #eee; + border-bottom: 1px solid #eee; font-size: 14px; &:first-child { diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index fabd726aae..07a3f45246 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -14,10 +14,10 @@ module ButtonHelper # # => "" # # See http://clipboardjs.com/#usage - def clipboard_button(data = {}, css_class: 'btn-clipboard') + def clipboard_button(data = {}) content_tag :button, icon('clipboard'), - class: "btn #{css_class}", + class: "btn", data: data, type: :button end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 97d52b1fb9..3dbb6e4a55 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -20,10 +20,7 @@ module CommitsHelper options = options.merge(source: :author) user = commit.send(options[:source]) - source_name = clean(commit.send "#{options[:source]}_name".to_sym) source_email = clean(commit.send "#{options[:source]}_email".to_sym) - - person_name = user.try(:name) || source_name person_email = user.try(:email) || source_email image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 2c0498de3b..79a3ed8197 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -202,8 +202,8 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I see Browse dir link' do - expect(page).to have_link 'Browse Directory »' - expect(page).not_to have_link 'Browse Code »' + expect(page).to have_link 'Browse Directory' + expect(page).not_to have_link 'Browse Code' end step 'I click on readme file' do @@ -219,7 +219,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps step 'I see Browse code link' do expect(page).to have_link 'Browse Files' - expect(page).not_to have_link 'Browse Directory »' + expect(page).not_to have_link 'Browse Directory' end step 'I click on Permalink' do From 97cee7e231689a7dee2f193411f3cd7962c6ea52 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:04:18 +0100 Subject: [PATCH 060/195] Improved spacing on mobile --- app/assets/stylesheets/pages/commits.scss | 14 +++++++++++--- app/helpers/commits_helper.rb | 2 +- app/views/projects/commits/_commit.html.haml | 6 ++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index ba8d9cce49..2723fb0b6e 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -57,7 +57,7 @@ } .commit-actions { - @media (min-width: $screen-md-min) { + @media (min-width: $screen-sm-min) { float: right; margin-left: $gl-padding; } @@ -69,7 +69,11 @@ } .commit { - padding: 10px 0 10px 55px; + padding: 10px 0 10px; + + @media (min-width: $screen-sm-min) { + padding-left: 55px; + } &:not(:last-child) { border-bottom: 1px solid #eee; @@ -78,6 +82,7 @@ a, button { color: $gl-dark-link-color; + vertical-align: baseline; } .avatar { @@ -86,7 +91,10 @@ .item-title { display: inline-block; - max-width: 70%; + + @media (min-width: $screen-sm-min) { + max-width: 70%; + } } .commit-row-description { diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 3dbb6e4a55..55d6569829 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -23,7 +23,7 @@ module CommitsHelper source_email = clean(commit.send "#{options[:source]}_email".to_sym) person_email = user.try(:email) || source_email - image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") + image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]} hidden-xs", width: options[:size], alt: "") end def image_diff_class(diff) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index d6661deb5f..58ccd31442 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -13,10 +13,12 @@ .commit-row-title %span.item-title = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" + - if commit.status + = render_commit_status(commit, cssclass: 'visible-xs-inline') - if commit.description? - %a.text-expander.js-toggle-button ... + %a.text-expander.hidden-xs.js-toggle-button ... - .commit-actions + .commit-actions.hidden-xs - if commit.status = render_commit_status(commit, cssclass: 'btn btn-transparent') = clipboard_button_with_class({ clipboard_text: commit.id }, css_class: 'btn-transparent') From d5afb1324f2fb9b9c19df3806662e159bbe4ffb3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:17:13 +0100 Subject: [PATCH 061/195] Sends correct parameter to commit_author_link for avatar --- app/views/projects/commits/_commit.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 58ccd31442..66df5fe5e2 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -30,6 +30,6 @@ = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) .commit-row-info - = commit_author_link(commit, avatar: true, size: 24) + = commit_author_link(commit, avatar: false, size: 24) authored #{time_ago_with_tooltip(commit.committed_date)} From 6863444b7e7d71e6b50ed8ab09cda6f3e0117176 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:40:46 +0100 Subject: [PATCH 062/195] SCSS lint fix --- app/assets/stylesheets/pages/commits.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 2723fb0b6e..93566be88d 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -69,7 +69,7 @@ } .commit { - padding: 10px 0 10px; + padding: 10px 0; @media (min-width: $screen-sm-min) { padding-left: 55px; From e5a83a9a94f7fbedb2fcce645248e1198dcf474f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 10:17:32 +0100 Subject: [PATCH 063/195] Added short commit ID to mobile --- app/assets/stylesheets/pages/commits.scss | 4 ---- app/views/projects/commits/_commit.html.haml | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 93566be88d..a392993b38 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -34,10 +34,6 @@ .commit-row-message { color: $gl-dark-link-color; - - &:hover { - text-decoration: underline; - } } .text-expander { diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 66df5fe5e2..757f4e7e8e 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -13,6 +13,9 @@ .commit-row-title %span.item-title = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" + %span.commit-row-message.visible-xs-inline + · + = commit.short_id - if commit.status = render_commit_status(commit, cssclass: 'visible-xs-inline') - if commit.description? From 41c2ea9b7a036da7064b433de43c19e578cc7531 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:09:46 +0100 Subject: [PATCH 064/195] Vertical alignment of buttons in commit row --- app/assets/stylesheets/pages/commits.scss | 6 +++--- app/views/projects/commits/_commit.html.haml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index a392993b38..335d9e5efd 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -20,8 +20,8 @@ } .commit-row-title { - line-height: 20px; - margin-bottom: 2px; + line-height: 1; + margin-bottom: 6px; .notes_count { float: right; @@ -115,7 +115,7 @@ .commit-row-info { color: $gl-gray; - line-height: 24px; + line-height: 1; a { color: $gl-gray; diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 757f4e7e8e..a959b34a53 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,7 +9,7 @@ = cache(cache_key) do %li.commit.js-toggle-container{ id: "commit-#{commit.short_id}" } - = commit_author_avatar(commit) + = commit_author_avatar(commit, size: 36) .commit-row-title %span.item-title = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" From e7ee3f9f4d34f74bb40c0e439a2f7920e55ba3ba Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:16:18 +0100 Subject: [PATCH 065/195] Changed margin to better align vertically --- app/assets/stylesheets/pages/commits.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 335d9e5efd..b954ed5094 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -21,7 +21,7 @@ .commit-row-title { line-height: 1; - margin-bottom: 6px; + margin-bottom: 5px; .notes_count { float: right; From b76ab726b2542d77d59b12457b62016d9205a5b2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Jun 2016 10:32:54 +0100 Subject: [PATCH 066/195] Fixed horizontal and veritcal alignment of commit action buttons --- app/assets/stylesheets/pages/commits.scss | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b954ed5094..811f0765a2 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -10,7 +10,7 @@ .commit-header { padding: 5px 10px; background-color: $background-color; - border-bottom: 1px solid #eee; + border-top: 1px solid #eee; border-bottom: 1px solid #eee; font-size: 14px; @@ -21,7 +21,7 @@ .commit-row-title { line-height: 1; - margin-bottom: 5px; + margin-bottom: 7px; .notes_count { float: right; @@ -37,6 +37,7 @@ } .text-expander { + display: inline-block; background: $gray-light; color: $gl-gray-dark; padding: 0 5px; @@ -56,6 +57,19 @@ @media (min-width: $screen-sm-min) { float: right; margin-left: $gl-padding; + margin-top: 2px; + font-size: 0; + } + + .btn-transparent { + padding-left: 0; + padding-right: 0; + } + + .btn { + &:not(:first-child) { + margin-left: $gl-padding; + } } } @@ -68,7 +82,7 @@ padding: 10px 0; @media (min-width: $screen-sm-min) { - padding-left: 55px; + padding-left: 46px; } &:not(:last-child) { @@ -82,7 +96,7 @@ } .avatar { - margin-left: -55px; + margin-left: -46px; } .item-title { From 57ec290f0c47d04745e49598c490735b3e650edb Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 8 Jun 2016 16:37:13 +0300 Subject: [PATCH 067/195] Updated CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index bee1a82497..b37c23de40 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -40,6 +40,7 @@ v 8.9.0 (unreleased) - Put project Files and Commits tabs under Code tab - Replace Colorize with Rainbow for coloring console output in Rake tasks. - An indicator is now displayed at the top of the comment field for confidential issues. + - Show categorised search queries in the search autocomplete v 8.8.4 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From c1818eec1d10688c05467a900f60259490b627c1 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:35 +0300 Subject: [PATCH 068/195] Inject group options. --- app/views/layouts/_search.html.haml | 12 +++++++++++- app/views/layouts/application.html.haml | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index b76e31f7dc..5c6429d07b 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -41,9 +41,19 @@ gl.projectOptions["#{@project.path}"] = { issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", - projectName: "#{@project.name}" + name: "#{@project.name}" }; + - if @group + :javascript + gl.groupOptions = gl.groupOptions || {}; + gl.groupOptions["#{@group.path}"] = { + name: "#{@group.name}", + issuesPath: "#{issues_group_path(@group.path)}", + mrPath: "#{merge_requests_group_path(@group.path)}" + }; + + :javascript gl.dashboardOptions = { issuesPath: "#{issues_dashboard_url}", diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 504abd8f3e..33cedaaf2e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: "en"} = render "layouts/head" - %body{class: "#{user_application_theme}", 'data-page' => body_data_page, 'data-project' => "#{@project.path if @project}"} + %body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}} = Gon::Base.render_data -# Ideally this would be inside the head, but turbolinks only evaluates page-specific JS in the body. From 7df512d5a209cc82b06020d6196a47d79b73f861 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:50 +0300 Subject: [PATCH 069/195] Add new utils. --- .../javascripts/lib/common_utils.js.coffee | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 3ec569f73e..95c4dd319a 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -3,11 +3,24 @@ w.gl or= {} w.gl.utils or= {} + w.gl.utils.isInGroupsPage = -> + + return $('body').data('page').split(':')[0] is 'groups' + + + w.gl.utils.isInProjectPage = -> + + return $('body').data('page').split(':')[0] is 'projects' + + w.gl.utils.getProjectSlug = -> - $body = $ 'body' - isInProjectPage = $body.data('page').split(':')[0] is 'projects' + return if @isInProjectPage() then $('body').data 'project' else null + + + w.gl.utils.getGroupSlug = -> + + return if @isInGroupsPage() then $('body').data 'group' else null - return if isInProjectPage then $body.data 'project' else null ) window From 522ef5754d222de1e8687c4b9bbc081478c69041 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:22:13 +0300 Subject: [PATCH 070/195] Refactor search autocomplete to support groups category contents. --- .../javascripts/search_autocomplete.js.coffee | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 8493d2684d..421328554b 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -68,8 +68,6 @@ class @SearchAutocomplete _this = @ unless term - return unless @hasLocationBadge() - if contents = @getCategoryContents() @searchInput.data('glDropdown').filter.options.callback contents @enableAutocomplete() @@ -131,18 +129,22 @@ class @SearchAutocomplete getCategoryContents: -> - userId = gon.current_user_id - projectName = 'Dashboard' - projectSlug = gl.utils.getProjectSlug() - projectOptions = gl.projectOptions?[projectSlug] + userId = gon.current_user_id + { utils, projectOptions, groupOptions, dashboardOptions } = gl - if projectSlug and projectOptions - { issuesPath, mrPath, projectName } = projectOptions - else - { issuesPath, mrPath } = gl.dashboardOptions + if utils.isInGroupsPage() and groupOptions + options = groupOptions[utils.getGroupSlug()] - return [ - { header: "Go to in #{projectName}" } + else if utils.isInProjectPage() and projectOptions + options = projectOptions[utils.getProjectSlug()] + + else if dashboardOptions + options = dashboardOptions + + { issuesPath, mrPath, name } = options + + items = [ + { header: "#{name}" } { text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" } { text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" } 'separator' @@ -150,6 +152,10 @@ class @SearchAutocomplete { text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" } ] + items.splice 0, 1 unless name + + return items + serializeState: -> { @@ -238,8 +244,7 @@ class @SearchAutocomplete @isFocused = true @wrap.addClass('search-active') - if @hasLocationBadge() and @getValue() is '' - @getData() + @getData() if @getValue() is '' getValue: -> return @searchInput.val() From 1f5ecf916ee7b1d34fbf8775890b2aada2055384 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 9 Jun 2016 14:08:49 +0530 Subject: [PATCH 071/195] Implement @jschatz1's comments. - No hardcoded colors in any SCSS file except `variables.scss` - Don't allow choosing a date in the past - Use the same table as in the "Applications" tab - The button should say "Create Personal Access Token" - Float the revoke button to the right of the table cell - Change the revocation message to be more explicit. - Date shouldn't look selected on page load - Don't use a panel for the created token - Use a normal flash for "Your new personal access token has been created" - Show the input (with the token) below it full width. - Put the "Make sure you save it - you won't be able to access it again." message near the input - Have the created token's input highlight all on single click --- .../stylesheets/framework/variables.scss | 5 ++ app/assets/stylesheets/pages/profile.scss | 18 ++----- .../personal_access_tokens_controller.rb | 2 +- .../personal_access_tokens/index.html.haml | 49 +++++++++++++------ .../profiles/personal_access_tokens_spec.rb | 12 ++--- 5 files changed, 48 insertions(+), 38 deletions(-) diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index f253da814b..fc3589dad6 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -256,3 +256,8 @@ $calendar-header-color: #b8b8b8; $calendar-hover-bg: #ecf3fe; $calendar-border-color: rgba(#000, .1); $calendar-unselectable-bg: #faf9f9; + +/* + * Personal Access Tokens + */ +$personal-access-tokens-disabled-label-color: #bbb; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index a2bbe734d2..88e062d156 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -192,24 +192,12 @@ } } -.personal-access-tokens-revoked-label { - color: #bbb; -} - .personal-access-tokens-never-expires-label { - color: #bbb; + color: $personal-access-tokens-disabled-label-color; } -.personal-access-tokens-token-column { - max-width: 500px -} - -.created-personal-access-token { - margin: 15px 15px 0 0; - pre { - max-width: 400px; - display: inline; - } +.datepicker.personal-access-tokens-expires-at .ui-state-disabled span { + text-align: center; } .user-profile { diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index a1545a5dd0..86e08fed8e 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -10,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController if @personal_access_token.save flash[:personal_access_token] = @personal_access_token.token - redirect_to profile_personal_access_tokens_path + redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." else load_personal_access_tokens render :index diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 64e632b20e..4ad07df42d 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -9,13 +9,16 @@ .col-lg-9 - if flash[:personal_access_token] - .panel.panel-success - .panel-heading Success! - .panel-body - Your new personal access token has been created. Make sure to save it - you can't see it again on this page. - .created-personal-access-token - %pre= flash[:personal_access_token] - = clipboard_button(clipboard_text: flash[:personal_access_token]) + .created-personal-access-token + %h5.prepend-top-0 + Your New Personal Access Token + .form-group + .input-group + = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block" + .input-group-addon= clipboard_button(clipboard_text: flash[:personal_access_token]) + %span#created-personal-access-token-help-block.help-block Make sure you save it - you won't be able to access it again. + + %hr %h5.prepend-top-0 Add a Personal Access Token @@ -33,10 +36,10 @@ .form-group = f.label :expires_at, class: 'label-light' = f.hidden_field :expires_at, class: "form-control", required: false - .datepicker + .datepicker.personal-access-tokens-expires-at .prepend-top-default - = f.submit 'Add Personal Access Token', class: "btn btn-create" + = f.submit 'Create Personal Access Token', class: "btn btn-create" %hr @@ -44,7 +47,7 @@ - if @active_personal_access_tokens.present? .table-responsive - %table.table.table-striped.table-hover.active-personal-access-tokens + %table.table.active-personal-access-tokens %thead %tr %th Name @@ -61,7 +64,7 @@ = token.expires_at.to_date.to_s(:medium) - else %span.personal-access-tokens-never-expires-label Never - %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger", data: { confirm: "Are you sure? This cannot be undone." } + %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this certificate? This action cannot be undone." } - else .settings-message.text-center @@ -73,7 +76,7 @@ - if @inactive_personal_access_tokens.present? .table-responsive - %table.table.table-striped.table-hover.inactive-personal-access-tokens + %table.table.inactive-personal-access-tokens %thead %tr %th Name @@ -90,7 +93,21 @@ :javascript - $(".datepicker").datepicker({ - dateFormat: "yy-mm-dd", - onSelect: function(dateText, inst) { $("#personal_access_token_expires_at").val(dateText) } - }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#personal_access_token_expires_at').val())); + var date = $('#personal_access_token_expires_at').val(); + + var datepicker = $(".datepicker").datepicker({ + altFormat: "yy-mm-dd", + altField: "#personal_access_token_expires_at", + minDate: 0 + }); + + if (date) { + datepicker.datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', date)); + } + else { + datepicker.datepicker("setDate", null); + } + + $("#created-personal-access-token").click(function() { + this.select(); + }); diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 5bba08d005..105c5dae34 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -12,7 +12,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end def created_personal_access_token - find(".created-personal-access-token pre") + find(".created-personal-access-token input").value end def disallow_personal_access_token_saves! @@ -30,8 +30,8 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do visit profile_personal_access_tokens_path fill_in "Name", with: FFaker::Product.brand - expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to have_text(PersonalAccessToken.last.token) + expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) expect(active_personal_access_tokens).to have_text("Never") end @@ -44,8 +44,8 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do find("a[title='Next']").click click_on "1" - expect {click_on "Add Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to have_text(PersonalAccessToken.last.token) + expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) end @@ -56,7 +56,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do visit profile_personal_access_tokens_path fill_in "Name", with: FFaker::Product.brand - expect { click_on "Add Personal Access Token" }.not_to change { PersonalAccessToken.count } + expect { click_on "Create Personal Access Token" }.not_to change { PersonalAccessToken.count } expect(page).to have_content("Name cannot be nil") end end From 1f192afa2abab5fcab693eaf3e0fa3c874cfb793 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 10:50:03 +0100 Subject: [PATCH 072/195] Updated text expander text color --- app/assets/stylesheets/pages/commits.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 811f0765a2..5a6e55cf63 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -39,7 +39,7 @@ .text-expander { display: inline-block; background: $gray-light; - color: $gl-gray-dark; + color: $gl-placeholder-color; padding: 0 5px; cursor: pointer; border: 1px solid $border-gray-dark; From e864bdf25b0082be8d0847fed6a2d16fe348ae59 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 10 Jun 2016 01:05:09 +0300 Subject: [PATCH 073/195] Fix specs and add new tests. --- spec/features/search_spec.rb | 4 +- .../search_autocomplete_spec.js.coffee | 104 +++++++++++------- 2 files changed, 64 insertions(+), 44 deletions(-) diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 4f4d4b1e3e..b9e63a7152 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -68,7 +68,7 @@ describe "Search", feature: true do it 'should show category search dropdown' do page.find('#search').click - expect(page).to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + expect(page).to have_selector('.dropdown-header', text: /#{project.name}/i) end end @@ -119,7 +119,7 @@ describe "Search", feature: true do end it 'should not display the category search dropdown' do - expect(page).not_to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + expect(page).not_to have_selector('.dropdown-header', text: /#{project.name}/i) end end end diff --git a/spec/javascripts/search_autocomplete_spec.js.coffee b/spec/javascripts/search_autocomplete_spec.js.coffee index 5212f5d223..e77177783a 100644 --- a/spec/javascripts/search_autocomplete_spec.js.coffee +++ b/spec/javascripts/search_autocomplete_spec.js.coffee @@ -13,18 +13,33 @@ window.gon.current_user_id = userId dashboardIssuesPath = '/dashboard/issues' dashboardMRsPath = '/dashboard/merge_requests' -projectIssuesPath = "/gitlab-org/gitlab-ce/issues" -projectMRsPath = "/gitlab-org/gitlab-ce/merge_requests" +projectIssuesPath = '/gitlab-org/gitlab-ce/issues' +projectMRsPath = '/gitlab-org/gitlab-ce/merge_requests' +groupIssuesPath = '/groups/gitlab-org/issues' +groupMRsPath = '/groups/gitlab-org/merge_requests' projectName = 'GitLab Community Edition' +groupName = 'Gitlab Org' + # Add required attributes to body before starting the test. -addBodyAttributes = (page = 'groups') -> +# section would be dashboard|group|project +addBodyAttributes = (section = 'dashboard') -> - $('body').removeAttr 'data-page' - $('body').removeAttr 'data-project' + $body = $ 'body' - $('body').data 'page', "#{page}:show" - $('body').data 'project', 'gitlab-ce' + $body.removeAttr 'data-page' + $body.removeAttr 'data-project' + $body.removeAttr 'data-group' + + switch section + when 'dashboard' + $body.data 'page', 'root:index' + when 'group' + $body.data 'page', 'groups:show' + $body.data 'group', 'gitlab-org' + when 'project' + $body.data 'page', 'projects:show' + $body.data 'project', 'gitlab-ce' # Mock `gl` object in window for dashboard specific page. App code will need it. @@ -47,7 +62,27 @@ mockProjectOptions = -> projectName : projectName -assertLinks = (list, a1, a2, a3, a4) -> +mockGroupOptions = -> + + window.gl or= {} + window.gl.groupOptions = + 'gitlab-org' : + issuesPath : groupIssuesPath + mrPath : groupMRsPath + projectName : groupName + + +assertLinks = (list, issuesPath, mrsPath) -> + + issuesAssignedToMeLink = "#{issuesPath}/?assignee_id=#{userId}" + issuesIHaveCreatedLink = "#{issuesPath}/?author_id=#{userId}" + mrsAssignedToMeLink = "#{mrsPath}/?assignee_id=#{userId}" + mrsIHaveCreatedLink = "#{mrsPath}/?author_id=#{userId}" + + a1 = "a[href='#{issuesAssignedToMeLink}']" + a2 = "a[href='#{issuesIHaveCreatedLink}']" + a3 = "a[href='#{mrsAssignedToMeLink}']" + a4 = "a[href='#{mrsIHaveCreatedLink}']" expect(list.find(a1).length).toBe 1 expect(list.find(a1).text()).toBe ' Issues assigned to me ' @@ -62,7 +97,6 @@ assertLinks = (list, a1, a2, a3, a4) -> expect(list.find(a4).text()).toBe " Merge requests I've created " - describe 'Search autocomplete dropdown', -> fixture.preload 'search_autocomplete.html' @@ -77,53 +111,39 @@ describe 'Search autocomplete dropdown', -> addBodyAttributes() mockDashboardOptions() - - # Focus input to show dropdown list. widget.searchInput.focus() - w = widget.wrap.find '.dropdown-menu' - l = w.find 'ul' + list = widget.wrap.find('.dropdown-menu').find 'ul' + assertLinks list, dashboardIssuesPath, dashboardMRsPath - # # Expect dropdown and dropdown header - expect(w.find('.dropdown-header').text()).toBe 'Go to in Dashboard' - # Create links then assert link urls and inner texts - issuesAssignedToMeLink = "#{dashboardIssuesPath}/?assignee_id=#{userId}" - issuesIHaveCreatedLink = "#{dashboardIssuesPath}/?author_id=#{userId}" - mrsAssignedToMeLink = "#{dashboardMRsPath}/?assignee_id=#{userId}" - mrsIHaveCreatedLink = "#{dashboardMRsPath}/?author_id=#{userId}" + it 'should show Group specific dropdown menu', -> - a1 = "a[href='#{issuesAssignedToMeLink}']" - a2 = "a[href='#{issuesIHaveCreatedLink}']" - a3 = "a[href='#{mrsAssignedToMeLink}']" - a4 = "a[href='#{mrsIHaveCreatedLink}']" + addBodyAttributes 'group' + mockGroupOptions() + widget.searchInput.focus() - assertLinks l, a1, a2, a3, a4 + list = widget.wrap.find('.dropdown-menu').find 'ul' + assertLinks list, groupIssuesPath, groupMRsPath it 'should show Project specific dropdown menu', -> - addBodyAttributes 'projects' + addBodyAttributes 'project' mockProjectOptions() - - # Focus input to show dropdown list. widget.searchInput.focus() - w = widget.wrap.find '.dropdown-menu' - l = w.find 'ul' + list = widget.wrap.find('.dropdown-menu').find 'ul' + assertLinks list, projectIssuesPath, projectMRsPath - # Expect dropdown and dropdown header - expect(w.find('.dropdown-header').text()).toBe "Go to in #{projectName}" - # Create links then verify link urls and inner texts - issuesAssignedToMeLink = "#{projectIssuesPath}/?assignee_id=#{userId}" - issuesIHaveCreatedLink = "#{projectIssuesPath}/?author_id=#{userId}" - mrsAssignedToMeLink = "#{projectMRsPath}/?assignee_id=#{userId}" - mrsIHaveCreatedLink = "#{projectMRsPath}/?author_id=#{userId}" + it 'should not show category related menu if there is text in the input', -> - a1 = "a[href='#{issuesAssignedToMeLink}']" - a2 = "a[href='#{issuesIHaveCreatedLink}']" - a3 = "a[href='#{mrsAssignedToMeLink}']" - a4 = "a[href='#{mrsIHaveCreatedLink}']" + addBodyAttributes 'project' + mockProjectOptions() + widget.searchInput.val 'help' + widget.searchInput.focus() - assertLinks l, a1, a2, a3, a4 + list = widget.wrap.find('.dropdown-menu').find 'ul' + link = "a[href='#{projectIssuesPath}/?assignee_id=#{userId}']" + expect(list.find(link).length).toBe 0 From e18a08fd89f59088db22208069370a85f6a33001 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 10 Jun 2016 08:49:05 +0530 Subject: [PATCH 074/195] Implement second round of comments from @jschatz1. - Just use a link for the clipboard button. Having a non-clickable container (that looks like a button) is confusing. - Use `text-danger` for the "you won't be able to access it again" message. - Highlight the created token so people know to look there. --- app/assets/stylesheets/pages/profile.scss | 11 +++++++++++ .../profiles/personal_access_tokens/index.html.haml | 11 ++++++----- spec/features/profiles/personal_access_tokens_spec.rb | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 88e062d156..46371ec687 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -200,6 +200,17 @@ text-align: center; } +.created-personal-access-token-container { + #created-personal-access-token { + width: 90%; + display: inline; + } + + .btn-clipboard { + margin-left: 5px; + } +} + .user-profile { @media (max-width: $screen-xs-max) { .cover-block { diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 4ad07df42d..456e770dc8 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -9,14 +9,13 @@ .col-lg-9 - if flash[:personal_access_token] - .created-personal-access-token + .created-personal-access-token-container %h5.prepend-top-0 Your New Personal Access Token .form-group - .input-group - = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block" - .input-group-addon= clipboard_button(clipboard_text: flash[:personal_access_token]) - %span#created-personal-access-token-help-block.help-block Make sure you save it - you won't be able to access it again. + = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block" + = clipboard_button(clipboard_text: flash[:personal_access_token]) + %span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again. %hr @@ -111,3 +110,5 @@ $("#created-personal-access-token").click(function() { this.select(); }); + + $("#created-personal-access-token").effect('highlight', { color: '#ffff99' }, 2000); diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 105c5dae34..d824e1d288 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -12,7 +12,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end def created_personal_access_token - find(".created-personal-access-token input").value + find("#created-personal-access-token").value end def disallow_personal_access_token_saves! From ffd07382b08586420628ae7ecda8a512adf091aa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 11:23:57 +0100 Subject: [PATCH 075/195] Fixed issue with bold in issuable sidebar --- app/assets/javascripts/users_select.js.coffee | 2 +- app/assets/stylesheets/pages/issuable.scss | 1 - app/views/shared/issuable/_sidebar.html.haml | 10 ++++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index de0eae58bf..de38d9fb26 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -84,7 +84,7 @@ class @UsersSelect <% } else { %> No assignee - - + assign yourself diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 787c387379..8b6370caa7 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -141,7 +141,6 @@ .assign-yourself { margin-top: 10px; - font-weight: normal; display: block; } } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fb906de829..a1f6defafc 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -29,8 +29,10 @@ %span.assign-yourself No assignee - if can_edit_issuable - %a.js-assign-yourself{ href: '#' } - \- assign yourself + %span.light + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' @@ -56,7 +58,7 @@ %span.has-tooltip{title: milestone_remaining_days(issuable.milestone), data: {container: 'body', html: 1}} = issuable.milestone.title - else - .light None + None .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil @@ -112,7 +114,7 @@ - issuable.labels_array.each do |label| = link_to_label(label, type: issuable.to_ability_name) - else - .light None + None .selectbox.hide-collapsed - issuable.labels_array.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil From 492e0062172b5a6ea1e553f97b2ac410badc496f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:03:33 +0100 Subject: [PATCH 076/195] Corrected all sidebar font weights to correctly match the design --- .../javascripts/due_date_select.js.coffee | 5 +-- .../javascripts/labels_select.js.coffee | 2 +- .../javascripts/milestone_select.js.coffee | 8 ++--- app/assets/javascripts/users_select.js.coffee | 6 ++-- app/assets/stylesheets/pages/issuable.scss | 4 +++ app/helpers/projects_helper.rb | 2 +- app/views/shared/issuable/_sidebar.html.haml | 31 +++++++++---------- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 3d009a96d0..d5cb3f620b 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -32,7 +32,7 @@ class @DueDateSelect date = new Date value.replace(new RegExp('-', 'g'), ',') mediumDate = $.datepicker.formatDate 'M d, yy', date else - mediumDate = 'None' + mediumDate = 'No due date' data = {} data[abilityName] = {} @@ -50,7 +50,8 @@ class @DueDateSelect $selectbox.hide() $value.css('display', '') - $valueContent.html(mediumDate) + cssClass = if mediumDate is "No due date" then 'no-value' else 'bold' + $valueContent.html("#{mediumDate}") $sidebarValue.html(mediumDate) if value isnt '' diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee index ec74dfaae1..6dff9fc4fd 100644 --- a/app/assets/javascripts/labels_select.js.coffee +++ b/app/assets/javascripts/labels_select.js.coffee @@ -39,7 +39,7 @@ class @LabelsSelect <% }); %>' ) - labelNoneHTMLTemplate = _.template('
    None
    ') + labelNoneHTMLTemplate = _.template('None') if newLabelField.length diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 648e1f3bde..a312103d82 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -24,14 +24,10 @@ class @MilestoneSelect if issueUpdateURL milestoneLinkTemplate = _.template( - ' - - <%= _.escape(title) %> - - ' + '<%= _.escape(title) %>' ) - milestoneLinkNoneTemplate = '
    None
    ' + milestoneLinkNoneTemplate = 'None' collapsedSidebarLabelTemplate = _.template( ' diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index de38d9fb26..cb7a700d02 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -72,7 +72,7 @@ class @UsersSelect assigneeTemplate = _.template( '<% if (username) { %> - + <% if( avatar ) { %> <% } %> @@ -82,9 +82,9 @@ class @UsersSelect <% } else { %> - + No assignee - - + assign yourself diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 8b6370caa7..fb95aa2283 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -153,6 +153,10 @@ font-weight: normal; } + .no-value { + color: $gl-placeholder-color; + } + .sidebar-collapsed-icon { display: none; } diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f..61c9a2254d 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -49,7 +49,7 @@ module ProjectsHelper author_html = author_html.html_safe if opts[:name] - link_to(author_html, user_path(author), class: "author_link #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe + link_to(author_html, user_path(author), class: "author_link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe else title = opts[:title].sub(":name", sanitize(author.name)) link_to(author_html, user_path(author), class: "author_link has-tooltip", title: title, data: { container: 'body' } ).html_safe diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index a1f6defafc..1daad44fa1 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -17,22 +17,21 @@ = icon('spinner spin', class: 'block-loading') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.hide-collapsed + .value.hide-collapsed - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32) do + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } = icon('exclamation-triangle') %span.username = issuable.assignee.to_reference - else - %span.assign-yourself + %span.assign-yourself.no-value No assignee - if can_edit_issuable - %span.light - \- - %a.js-assign-yourself{ href: '#' } - assign yourself + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' @@ -52,13 +51,11 @@ = icon('spinner spin', class: 'block-loading') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.hide-collapsed + .value.hide-collapsed - if issuable.milestone - = link_to namespace_project_milestone_path(@project.namespace, @project, issuable.milestone) do - %span.has-tooltip{title: milestone_remaining_days(issuable.milestone), data: {container: 'body', html: 1}} - = issuable.milestone.title + = link_to issuable.milestone.title, namespace_project_milestone_path(@project.namespace, @project, issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 } - else - None + %span.no-value None .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil @@ -75,14 +72,14 @@ = icon('spinner spin', class: 'block-loading') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.hide-collapsed + .value.hide-collapsed %span.value-content - if issuable.due_date - = issuable.due_date.to_s(:medium) + %span.bold= issuable.due_date.to_s(:medium) - else - None + %span.no-value No due date - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - %span.light.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } + %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } \- %a.js-remove-due-date{ href: "#", role: "button" } remove due date @@ -114,7 +111,7 @@ - issuable.labels_array.each do |label| = link_to_label(label, type: issuable.to_ability_name) - else - None + %span.no-value None .selectbox.hide-collapsed - issuable.labels_array.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil From 88f562469f20be70392cf30c34f421ea9b68bcb2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:05:32 +0100 Subject: [PATCH 077/195] Updated link color --- app/assets/stylesheets/pages/issuable.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index fb95aa2283..4145b26ed1 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -154,7 +154,7 @@ } .no-value { - color: $gl-placeholder-color; + color: #8c8c8c; } .sidebar-collapsed-icon { From e15b17f855c03cb993d446985fccf36b13d4bd25 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:06:07 +0100 Subject: [PATCH 078/195] Uses already defined color for text in sidebar --- app/assets/stylesheets/pages/issuable.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 4145b26ed1..ba658d9fac 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -154,7 +154,7 @@ } .no-value { - color: #8c8c8c; + color: $gl-placeholder-color; } .sidebar-collapsed-icon { @@ -321,7 +321,7 @@ margin-left: 5px; a { - color: #8c8c8c; + color: $gl-placeholder-color; } } From b0cf82105bbb3d61e559e8bb36abfa308cbee6ae Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:24:31 +0100 Subject: [PATCH 079/195] Fixed tests --- spec/features/issues_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index f6fb6a72d2..32d1e63140 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -515,10 +515,10 @@ describe 'Issues', feature: true do first('.ui-state-default').click end - expect(page).to have_no_content 'None' + expect(page).to have_no_content 'No due date' click_link 'remove due date' - expect(page).to have_content 'None' + expect(page).to have_content 'No due date' end end end From a99d25b174e07bf58ae8d0c5055291065038f81a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 08:42:03 +0100 Subject: [PATCH 080/195] Checks against date parsing instead of string Removes template for html string that isn't needed --- app/assets/javascripts/due_date_select.js.coffee | 2 +- app/assets/javascripts/labels_select.js.coffee | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index d5cb3f620b..32c143cae1 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -50,7 +50,7 @@ class @DueDateSelect $selectbox.hide() $value.css('display', '') - cssClass = if mediumDate is "No due date" then 'no-value' else 'bold' + cssClass = if Date.parse(mediumDate) then 'bold' else 'no-value' $valueContent.html("#{mediumDate}") $sidebarValue.html(mediumDate) diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee index 6dff9fc4fd..5df3af6091 100644 --- a/app/assets/javascripts/labels_select.js.coffee +++ b/app/assets/javascripts/labels_select.js.coffee @@ -39,7 +39,7 @@ class @LabelsSelect <% }); %>' ) - labelNoneHTMLTemplate = _.template('None') + labelNoneHTMLTemplate = 'None' if newLabelField.length @@ -142,7 +142,7 @@ class @LabelsSelect template = labelHTMLTemplate(data) labelCount = data.labels.length else - template = labelNoneHTMLTemplate() + template = labelNoneHTMLTemplate $value .removeAttr('style') .html(template) From 9d09bd08e541c42dc05e336293cc2917b3a60df8 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 08:30:38 +0100 Subject: [PATCH 081/195] Due date can be removed from milestones Closes #15063 --- CHANGELOG | 2 ++ app/assets/javascripts/dispatcher.js.coffee | 1 + app/assets/javascripts/due_date_select.js.coffee | 15 +++++++++++++++ app/views/projects/milestones/_form.html.haml | 8 +------- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b00c149a75..07c7ad19c5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,8 @@ v 8.8.5 (unreleased) v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 + - Added descriptions to notification settings dropdown + - Due date can be removed from milestones v 8.8.3 - Fix 404 page when viewing TODOs that contain milestones or labels in different projects. !4312 diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b3..08ab960436 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -29,6 +29,7 @@ class Dispatcher new Todos() when 'projects:milestones:new', 'projects:milestones:edit' new ZenMode() + new DueDateSelect() new GLForm($('.milestone-form')) when 'groups:milestones:new' new ZenMode() diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 3d009a96d0..99d59eca9c 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -1,5 +1,20 @@ class @DueDateSelect constructor: -> + # Milestone edit/new form + $datePicker = $('.datepicker') + $dueDate = $('#milestone_due_date') + $datePicker.datepicker + dateFormat: 'yy-mm-dd' + onSelect: (dateText, inst) -> + $dueDate.val(dateText) + .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) + + $('.js-clear-due-date').on 'click', (e) -> + e.preventDefault() + $dueDate.val('') + $datePicker.datepicker('setDate', '') + + # Issuable sidebar $loading = $('.js-issuable-update .due_date') .find('.block-loading') .hide() diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index f5e2b927da..cbf1ba0417 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -19,6 +19,7 @@ = f.label :due_date, "Due Date", class: "control-label" .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date" + %a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date .form-actions - if @milestone.new_record? @@ -27,10 +28,3 @@ -else = f.submit 'Save changes', class: "btn-save btn" = link_to "Cancel", namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-cancel" - - -:javascript - $(".datepicker").datepicker({ - dateFormat: "yy-mm-dd", - onSelect: function(dateText, inst) { $("#milestone_due_date").val(dateText) } - }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#milestone_due_date').val())); From ab722343ada1f79cc7274721ec707dc7760f3fce Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:23:17 +0100 Subject: [PATCH 082/195] Fixed tests --- app/assets/javascripts/due_date_select.js.coffee | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 99d59eca9c..401433b073 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -2,12 +2,14 @@ class @DueDateSelect constructor: -> # Milestone edit/new form $datePicker = $('.datepicker') - $dueDate = $('#milestone_due_date') - $datePicker.datepicker - dateFormat: 'yy-mm-dd' - onSelect: (dateText, inst) -> - $dueDate.val(dateText) - .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) + + if $datePicker.length + $dueDate = $('#milestone_due_date') + $datePicker.datepicker + dateFormat: 'yy-mm-dd' + onSelect: (dateText, inst) -> + $dueDate.val(dateText) + .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) $('.js-clear-due-date').on 'click', (e) -> e.preventDefault() From ff702bd6e05520a2cb695a03984a35ed981c6561 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 12:04:21 +0100 Subject: [PATCH 083/195] Changed how date gets cleared --- app/assets/javascripts/due_date_select.js.coffee | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 401433b073..2a79a0c3d8 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -13,8 +13,7 @@ class @DueDateSelect $('.js-clear-due-date').on 'click', (e) -> e.preventDefault() - $dueDate.val('') - $datePicker.datepicker('setDate', '') + $.datepicker._clearDate($datePicker) # Issuable sidebar $loading = $('.js-issuable-update .due_date') From 18e16e427d331415db042afd3c8dd5689db32a53 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Tue, 7 Jun 2016 15:07:00 -0600 Subject: [PATCH 084/195] Replace raphael-rails with raphael.js so it can be split from the rest of the JavaScript. The gem isn't maintained anymore anyway. Added a network folder with an application.js including raphael components, since that's the only page using it currently. --- Gemfile | 1 - Gemfile.lock | 2 - app/assets/javascripts/application.js.coffee | 4 - .../javascripts/network/application.js.coffee | 20 + .../{ => network}/branch-graph.js.coffee | 0 .../{ => network}/network.js.coffee | 0 app/views/projects/network/show.html.haml | 12 +- config/application.rb | 1 + vendor/assets/javascripts/raphael.js | 8239 +++++++++++++++++ 9 files changed, 8262 insertions(+), 17 deletions(-) create mode 100644 app/assets/javascripts/network/application.js.coffee rename app/assets/javascripts/{ => network}/branch-graph.js.coffee (100%) rename app/assets/javascripts/{ => network}/network.js.coffee (100%) create mode 100644 vendor/assets/javascripts/raphael.js diff --git a/Gemfile b/Gemfile index b2660144f2..5a058e1004 100644 --- a/Gemfile +++ b/Gemfile @@ -224,7 +224,6 @@ gem 'gon', '~> 6.0.1' gem 'jquery-atwho-rails', '~> 1.3.2' gem 'jquery-rails', '~> 4.1.0' gem 'jquery-ui-rails', '~> 5.0.0' -gem 'raphael-rails', '~> 2.1.2' gem 'request_store', '~> 1.3.0' gem 'select2-rails', '~> 3.5.9' gem 'virtus', '~> 1.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index dfc1570049..00276d238c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -563,7 +563,6 @@ GEM rainbow (2.1.0) raindrops (0.15.0) rake (10.5.0) - raphael-rails (2.1.2) rb-fsevent (0.9.6) rb-inotify (0.9.5) ffi (>= 0.5.0) @@ -952,7 +951,6 @@ DEPENDENCIES rails (= 4.2.6) rails-deprecated_sanitizer (~> 1.0.3) rainbow (~> 2.1.0) - raphael-rails (~> 2.1.2) rblineprof rdoc (~> 3.6) recaptcha (~> 3.0) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index b28327ce12..228f48ad7c 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -32,10 +32,6 @@ #= require bootstrap/tooltip #= require bootstrap/popover #= require select2 -#= require raphael -#= require g.raphael -#= require g.bar -#= require branch-graph #= require ace/ace #= require ace/ext-searchbox #= require underscore diff --git a/app/assets/javascripts/network/application.js.coffee b/app/assets/javascripts/network/application.js.coffee new file mode 100644 index 0000000000..cb9eead855 --- /dev/null +++ b/app/assets/javascripts/network/application.js.coffee @@ -0,0 +1,20 @@ +# This is a manifest file that'll be compiled into including all the files listed below. +# Add new JavaScript/Coffee code in separate files in this directory and they'll automatically +# be included in the compiled file accessible from http://example.com/assets/application.js +# It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +# the compiled file. +# +#= require raphael +#= require g.raphael +#= require g.bar +#= require_tree . + +$ -> + network_graph = new Network({ + url: $(".network-graph").attr('data-url'), + commit_url: $(".network-graph").attr('data-commit-url'), + ref: $(".network-graph").attr('data-ref'), + commit_id: $(".network-graph").attr('data-commit-id') + }) + + new ShortcutsNetwork(network_graph.branch_graph) diff --git a/app/assets/javascripts/branch-graph.js.coffee b/app/assets/javascripts/network/branch-graph.js.coffee similarity index 100% rename from app/assets/javascripts/branch-graph.js.coffee rename to app/assets/javascripts/network/branch-graph.js.coffee diff --git a/app/assets/javascripts/network.js.coffee b/app/assets/javascripts/network/network.js.coffee similarity index 100% rename from app/assets/javascripts/network.js.coffee rename to app/assets/javascripts/network/network.js.coffee diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index bf9baaea88..3c155e97f7 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -1,4 +1,5 @@ - page_title "Network", @ref +- page_specific_javascripts asset_path("network/application.js") = render "projects/commits/head" = render "head" %div{ class: (container_class) } @@ -14,14 +15,5 @@ = check_box_tag :filter_ref, 1, @options[:filter_ref] %span Begin with the selected commit - .network-graph + .network-graph{ data: { url: "#{escape_javascript(@url)}", commit_url: "#{escape_javascript(@commit_url)}", ref: "#{escape_javascript(@ref)}", commit_id: "#{escape_javascript(@commit.id)}" } } = spinner nil, true - -:javascript - network_graph = new Network({ - url: "#{escape_javascript(@url)}", - commit_url: "#{escape_javascript(@commit_url)}", - ref: "#{escape_javascript(@ref)}", - commit_id: '#{@commit.id}' - }) - new ShortcutsNetwork(network_graph.branch_graph) diff --git a/config/application.rb b/config/application.rb index 49d4d3ba55..05fec995ed 100644 --- a/config/application.rb +++ b/config/application.rb @@ -83,6 +83,7 @@ module Gitlab config.assets.precompile << "mailers/*.css" config.assets.precompile << "graphs/application.js" config.assets.precompile << "users/application.js" + config.assets.precompile << "network/application.js" # Version of your assets, change this if you want to expire all your assets config.assets.version = '1.0' diff --git a/vendor/assets/javascripts/raphael.js b/vendor/assets/javascripts/raphael.js new file mode 100644 index 0000000000..3f3f8a0b7f --- /dev/null +++ b/vendor/assets/javascripts/raphael.js @@ -0,0 +1,8239 @@ +// ┌────────────────────────────────────────────────────────────────────┐ \\ +// │ Raphaël 2.1.4 - JavaScript Vector Library │ \\ +// ├────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright © 2008-2012 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright © 2008-2012 Sencha Labs (http://sencha.com) │ \\ +// ├────────────────────────────────────────────────────────────────────┤ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license.│ \\ +// └────────────────────────────────────────────────────────────────────┘ \\ +// Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ┌────────────────────────────────────────────────────────────┐ \\ +// │ Eve 0.4.2 - JavaScript Events Library │ \\ +// ├────────────────────────────────────────────────────────────┤ \\ +// │ Author Dmitry Baranovskiy (http://dmitry.baranovskiy.com/) │ \\ +// └────────────────────────────────────────────────────────────┘ \\ + +(function (glob) { + var version = "0.4.2", + has = "hasOwnProperty", + separator = /[\.\/]/, + wildcard = "*", + fun = function () {}, + numsort = function (a, b) { + return a - b; + }, + current_event, + stop, + events = {n: {}}, + /*\ + * eve + [ method ] + + * Fires event with given `name`, given scope and other parameters. + + > Arguments + + - name (string) name of the *event*, dot (`.`) or slash (`/`) separated + - scope (object) context for the event handlers + - varargs (...) the rest of arguments will be sent to event handlers + + = (object) array of returned values from the listeners + \*/ + eve = function (name, scope) { + name = String(name); + var e = events, + oldstop = stop, + args = Array.prototype.slice.call(arguments, 2), + listeners = eve.listeners(name), + z = 0, + f = false, + l, + indexed = [], + queue = {}, + out = [], + ce = current_event, + errors = []; + current_event = name; + stop = 0; + for (var i = 0, ii = listeners.length; i < ii; i++) if ("zIndex" in listeners[i]) { + indexed.push(listeners[i].zIndex); + if (listeners[i].zIndex < 0) { + queue[listeners[i].zIndex] = listeners[i]; + } + } + indexed.sort(numsort); + while (indexed[z] < 0) { + l = queue[indexed[z++]]; + out.push(l.apply(scope, args)); + if (stop) { + stop = oldstop; + return out; + } + } + for (i = 0; i < ii; i++) { + l = listeners[i]; + if ("zIndex" in l) { + if (l.zIndex == indexed[z]) { + out.push(l.apply(scope, args)); + if (stop) { + break; + } + do { + z++; + l = queue[indexed[z]]; + l && out.push(l.apply(scope, args)); + if (stop) { + break; + } + } while (l) + } else { + queue[l.zIndex] = l; + } + } else { + out.push(l.apply(scope, args)); + if (stop) { + break; + } + } + } + stop = oldstop; + current_event = ce; + return out.length ? out : null; + }; + // Undocumented. Debug only. + eve._events = events; + /*\ + * eve.listeners + [ method ] + + * Internal method which gives you array of all event handlers that will be triggered by the given `name`. + + > Arguments + + - name (string) name of the event, dot (`.`) or slash (`/`) separated + + = (array) array of event handlers + \*/ + eve.listeners = function (name) { + var names = name.split(separator), + e = events, + item, + items, + k, + i, + ii, + j, + jj, + nes, + es = [e], + out = []; + for (i = 0, ii = names.length; i < ii; i++) { + nes = []; + for (j = 0, jj = es.length; j < jj; j++) { + e = es[j].n; + items = [e[names[i]], e[wildcard]]; + k = 2; + while (k--) { + item = items[k]; + if (item) { + nes.push(item); + out = out.concat(item.f || []); + } + } + } + es = nes; + } + return out; + }; + + /*\ + * eve.on + [ method ] + ** + * Binds given event handler with a given name. You can use wildcards “`*`” for the names: + | eve.on("*.under.*", f); + | eve("mouse.under.floor"); // triggers f + * Use @eve to trigger the listener. + ** + > Arguments + ** + - name (string) name of the event, dot (`.`) or slash (`/`) separated, with optional wildcards + - f (function) event handler function + ** + = (function) returned function accepts a single numeric parameter that represents z-index of the handler. It is an optional feature and only used when you need to ensure that some subset of handlers will be invoked in a given order, despite of the order of assignment. + > Example: + | eve.on("mouse", eatIt)(2); + | eve.on("mouse", scream); + | eve.on("mouse", catchIt)(1); + * This will ensure that `catchIt()` function will be called before `eatIt()`. + * + * If you want to put your handler before non-indexed handlers, specify a negative value. + * Note: I assume most of the time you don’t need to worry about z-index, but it’s nice to have this feature “just in case”. + \*/ + eve.on = function (name, f) { + name = String(name); + if (typeof f != "function") { + return function () {}; + } + var names = name.split(separator), + e = events; + for (var i = 0, ii = names.length; i < ii; i++) { + e = e.n; + e = e.hasOwnProperty(names[i]) && e[names[i]] || (e[names[i]] = {n: {}}); + } + e.f = e.f || []; + for (i = 0, ii = e.f.length; i < ii; i++) if (e.f[i] == f) { + return fun; + } + e.f.push(f); + return function (zIndex) { + if (+zIndex == +zIndex) { + f.zIndex = +zIndex; + } + }; + }; + /*\ + * eve.f + [ method ] + ** + * Returns function that will fire given event with optional arguments. + * Arguments that will be passed to the result function will be also + * concated to the list of final arguments. + | el.onclick = eve.f("click", 1, 2); + | eve.on("click", function (a, b, c) { + | console.log(a, b, c); // 1, 2, [event object] + | }); + > Arguments + - event (string) event name + - varargs (…) and any other arguments + = (function) possible event handler function + \*/ + eve.f = function (event) { + var attrs = [].slice.call(arguments, 1); + return function () { + eve.apply(null, [event, null].concat(attrs).concat([].slice.call(arguments, 0))); + }; + }; + /*\ + * eve.stop + [ method ] + ** + * Is used inside an event handler to stop the event, preventing any subsequent listeners from firing. + \*/ + eve.stop = function () { + stop = 1; + }; + /*\ + * eve.nt + [ method ] + ** + * Could be used inside event handler to figure out actual name of the event. + ** + > Arguments + ** + - subname (string) #optional subname of the event + ** + = (string) name of the event, if `subname` is not specified + * or + = (boolean) `true`, if current event’s name contains `subname` + \*/ + eve.nt = function (subname) { + if (subname) { + return new RegExp("(?:\\.|\\/|^)" + subname + "(?:\\.|\\/|$)").test(current_event); + } + return current_event; + }; + /*\ + * eve.nts + [ method ] + ** + * Could be used inside event handler to figure out actual name of the event. + ** + ** + = (array) names of the event + \*/ + eve.nts = function () { + return current_event.split(separator); + }; + /*\ + * eve.off + [ method ] + ** + * Removes given function from the list of event listeners assigned to given name. + * If no arguments specified all the events will be cleared. + ** + > Arguments + ** + - name (string) name of the event, dot (`.`) or slash (`/`) separated, with optional wildcards + - f (function) event handler function + \*/ + /*\ + * eve.unbind + [ method ] + ** + * See @eve.off + \*/ + eve.off = eve.unbind = function (name, f) { + if (!name) { + eve._events = events = {n: {}}; + return; + } + var names = name.split(separator), + e, + key, + splice, + i, ii, j, jj, + cur = [events]; + for (i = 0, ii = names.length; i < ii; i++) { + for (j = 0; j < cur.length; j += splice.length - 2) { + splice = [j, 1]; + e = cur[j].n; + if (names[i] != wildcard) { + if (e[names[i]]) { + splice.push(e[names[i]]); + } + } else { + for (key in e) if (e[has](key)) { + splice.push(e[key]); + } + } + cur.splice.apply(cur, splice); + } + } + for (i = 0, ii = cur.length; i < ii; i++) { + e = cur[i]; + while (e.n) { + if (f) { + if (e.f) { + for (j = 0, jj = e.f.length; j < jj; j++) if (e.f[j] == f) { + e.f.splice(j, 1); + break; + } + !e.f.length && delete e.f; + } + for (key in e.n) if (e.n[has](key) && e.n[key].f) { + var funcs = e.n[key].f; + for (j = 0, jj = funcs.length; j < jj; j++) if (funcs[j] == f) { + funcs.splice(j, 1); + break; + } + !funcs.length && delete e.n[key].f; + } + } else { + delete e.f; + for (key in e.n) if (e.n[has](key) && e.n[key].f) { + delete e.n[key].f; + } + } + e = e.n; + } + } + }; + /*\ + * eve.once + [ method ] + ** + * Binds given event handler with a given name to only run once then unbind itself. + | eve.once("login", f); + | eve("login"); // triggers f + | eve("login"); // no listeners + * Use @eve to trigger the listener. + ** + > Arguments + ** + - name (string) name of the event, dot (`.`) or slash (`/`) separated, with optional wildcards + - f (function) event handler function + ** + = (function) same return function as @eve.on + \*/ + eve.once = function (name, f) { + var f2 = function () { + eve.unbind(name, f2); + return f.apply(this, arguments); + }; + return eve.on(name, f2); + }; + /*\ + * eve.version + [ property (string) ] + ** + * Current version of the library. + \*/ + eve.version = version; + eve.toString = function () { + return "You are running Eve " + version; + }; + (typeof module != "undefined" && module.exports) ? (module.exports = eve) : (typeof define != "undefined" ? (define("eve", [], function() { return eve; })) : (glob.eve = eve)); +})(window || this); +// ┌─────────────────────────────────────────────────────────────────────┐ \\ +// │ "Raphaël 2.1.2" - JavaScript Vector Library │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright (c) 2008-2011 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright (c) 2008-2011 Sencha Labs (http://sencha.com) │ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license. │ \\ +// └─────────────────────────────────────────────────────────────────────┘ \\ + +(function (glob, factory) { + // AMD support + if (typeof define === "function" && define.amd) { + // Define as an anonymous module + define(["eve"], function( eve ) { + return factory(glob, eve); + }); + } else { + // Browser globals (glob is window) + // Raphael adds itself to window + factory(glob, glob.eve || (typeof require == "function" && require('eve')) ); + } +}(this, function (window, eve) { + /*\ + * Raphael + [ method ] + ** + * Creates a canvas object on which to draw. + * You must do this first, as all future calls to drawing methods + * from this instance will be bound to this canvas. + > Parameters + ** + - container (HTMLElement|string) DOM element or its ID which is going to be a parent for drawing surface + - width (number) + - height (number) + - callback (function) #optional callback function which is going to be executed in the context of newly created paper + * or + - x (number) + - y (number) + - width (number) + - height (number) + - callback (function) #optional callback function which is going to be executed in the context of newly created paper + * or + - all (array) (first 3 or 4 elements in the array are equal to [containerID, width, height] or [x, y, width, height]. The rest are element descriptions in format {type: type, }). See @Paper.add. + - callback (function) #optional callback function which is going to be executed in the context of newly created paper + * or + - onReadyCallback (function) function that is going to be called on DOM ready event. You can also subscribe to this event via Eve’s “DOMLoad” event. In this case method returns `undefined`. + = (object) @Paper + > Usage + | // Each of the following examples create a canvas + | // that is 320px wide by 200px high. + | // Canvas is created at the viewport’s 10,50 coordinate. + | var paper = Raphael(10, 50, 320, 200); + | // Canvas is created at the top left corner of the #notepad element + | // (or its top right corner in dir="rtl" elements) + | var paper = Raphael(document.getElementById("notepad"), 320, 200); + | // Same as above + | var paper = Raphael("notepad", 320, 200); + | // Image dump + | var set = Raphael(["notepad", 320, 200, { + | type: "rect", + | x: 10, + | y: 10, + | width: 25, + | height: 25, + | stroke: "#f00" + | }, { + | type: "text", + | x: 30, + | y: 40, + | text: "Dump" + | }]); + \*/ + function R(first) { + if (R.is(first, "function")) { + return loaded ? first() : eve.on("raphael.DOMload", first); + } else if (R.is(first, array)) { + return R._engine.create[apply](R, first.splice(0, 3 + R.is(first[0], nu))).add(first); + } else { + var args = Array.prototype.slice.call(arguments, 0); + if (R.is(args[args.length - 1], "function")) { + var f = args.pop(); + return loaded ? f.call(R._engine.create[apply](R, args)) : eve.on("raphael.DOMload", function () { + f.call(R._engine.create[apply](R, args)); + }); + } else { + return R._engine.create[apply](R, arguments); + } + } + } + R.version = "2.1.2"; + R.eve = eve; + var loaded, + separator = /[, ]+/, + elements = {circle: 1, rect: 1, path: 1, ellipse: 1, text: 1, image: 1}, + formatrg = /\{(\d+)\}/g, + proto = "prototype", + has = "hasOwnProperty", + g = { + doc: document, + win: window + }, + oldRaphael = { + was: Object.prototype[has].call(g.win, "Raphael"), + is: g.win.Raphael + }, + Paper = function () { + /*\ + * Paper.ca + [ property (object) ] + ** + * Shortcut for @Paper.customAttributes + \*/ + /*\ + * Paper.customAttributes + [ property (object) ] + ** + * If you have a set of attributes that you would like to represent + * as a function of some number you can do it easily with custom attributes: + > Usage + | paper.customAttributes.hue = function (num) { + | num = num % 1; + | return {fill: "hsb(" + num + ", 0.75, 1)"}; + | }; + | // Custom attribute “hue” will change fill + | // to be given hue with fixed saturation and brightness. + | // Now you can use it like this: + | var c = paper.circle(10, 10, 10).attr({hue: .45}); + | // or even like this: + | c.animate({hue: 1}, 1e3); + | + | // You could also create custom attribute + | // with multiple parameters: + | paper.customAttributes.hsb = function (h, s, b) { + | return {fill: "hsb(" + [h, s, b].join(",") + ")"}; + | }; + | c.attr({hsb: "0.5 .8 1"}); + | c.animate({hsb: [1, 0, 0.5]}, 1e3); + \*/ + this.ca = this.customAttributes = {}; + }, + paperproto, + appendChild = "appendChild", + apply = "apply", + concat = "concat", + supportsTouch = ('ontouchstart' in g.win) || g.win.DocumentTouch && g.doc instanceof DocumentTouch, //taken from Modernizr touch test + E = "", + S = " ", + Str = String, + split = "split", + events = "click dblclick mousedown mousemove mouseout mouseover mouseup touchstart touchmove touchend touchcancel"[split](S), + touchMap = { + mousedown: "touchstart", + mousemove: "touchmove", + mouseup: "touchend" + }, + lowerCase = Str.prototype.toLowerCase, + math = Math, + mmax = math.max, + mmin = math.min, + abs = math.abs, + pow = math.pow, + PI = math.PI, + nu = "number", + string = "string", + array = "array", + toString = "toString", + fillString = "fill", + objectToString = Object.prototype.toString, + paper = {}, + push = "push", + ISURL = R._ISURL = /^url\(['"]?(.+?)['"]?\)$/i, + colourRegExp = /^\s*((#[a-f\d]{6})|(#[a-f\d]{3})|rgba?\(\s*([\d\.]+%?\s*,\s*[\d\.]+%?\s*,\s*[\d\.]+%?(?:\s*,\s*[\d\.]+%?)?)\s*\)|hsba?\(\s*([\d\.]+(?:deg|\xb0|%)?\s*,\s*[\d\.]+%?\s*,\s*[\d\.]+(?:%?\s*,\s*[\d\.]+)?)%?\s*\)|hsla?\(\s*([\d\.]+(?:deg|\xb0|%)?\s*,\s*[\d\.]+%?\s*,\s*[\d\.]+(?:%?\s*,\s*[\d\.]+)?)%?\s*\))\s*$/i, + isnan = {"NaN": 1, "Infinity": 1, "-Infinity": 1}, + bezierrg = /^(?:cubic-)?bezier\(([^,]+),([^,]+),([^,]+),([^\)]+)\)/, + round = math.round, + setAttribute = "setAttribute", + toFloat = parseFloat, + toInt = parseInt, + upperCase = Str.prototype.toUpperCase, + availableAttrs = R._availableAttrs = { + "arrow-end": "none", + "arrow-start": "none", + blur: 0, + "clip-rect": "0 0 1e9 1e9", + cursor: "default", + cx: 0, + cy: 0, + fill: "#fff", + "fill-opacity": 1, + font: '10px "Arial"', + "font-family": '"Arial"', + "font-size": "10", + "font-style": "normal", + "font-weight": 400, + gradient: 0, + height: 0, + href: "http://raphaeljs.com/", + "letter-spacing": 0, + opacity: 1, + path: "M0,0", + r: 0, + rx: 0, + ry: 0, + src: "", + stroke: "#000", + "stroke-dasharray": "", + "stroke-linecap": "butt", + "stroke-linejoin": "butt", + "stroke-miterlimit": 0, + "stroke-opacity": 1, + "stroke-width": 1, + target: "_blank", + "text-anchor": "middle", + title: "Raphael", + transform: "", + width: 0, + x: 0, + y: 0 + }, + availableAnimAttrs = R._availableAnimAttrs = { + blur: nu, + "clip-rect": "csv", + cx: nu, + cy: nu, + fill: "colour", + "fill-opacity": nu, + "font-size": nu, + height: nu, + opacity: nu, + path: "path", + r: nu, + rx: nu, + ry: nu, + stroke: "colour", + "stroke-opacity": nu, + "stroke-width": nu, + transform: "transform", + width: nu, + x: nu, + y: nu + }, + whitespace = /[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]/g, + commaSpaces = /[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*/, + hsrg = {hs: 1, rg: 1}, + p2s = /,?([achlmqrstvxz]),?/gi, + pathCommand = /([achlmrqstvz])[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029,]*((-?\d*\.?\d*(?:e[\-+]?\d+)?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*)+)/ig, + tCommand = /([rstm])[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029,]*((-?\d*\.?\d*(?:e[\-+]?\d+)?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*)+)/ig, + pathValues = /(-?\d*\.?\d*(?:e[\-+]?\d+)?)[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*/ig, + radial_gradient = R._radial_gradient = /^r(?:\(([^,]+?)[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*([^\)]+?)\))?/, + eldata = {}, + sortByKey = function (a, b) { + return a.key - b.key; + }, + sortByNumber = function (a, b) { + return toFloat(a) - toFloat(b); + }, + fun = function () {}, + pipe = function (x) { + return x; + }, + rectPath = R._rectPath = function (x, y, w, h, r) { + if (r) { + return [["M", x + r, y], ["l", w - r * 2, 0], ["a", r, r, 0, 0, 1, r, r], ["l", 0, h - r * 2], ["a", r, r, 0, 0, 1, -r, r], ["l", r * 2 - w, 0], ["a", r, r, 0, 0, 1, -r, -r], ["l", 0, r * 2 - h], ["a", r, r, 0, 0, 1, r, -r], ["z"]]; + } + return [["M", x, y], ["l", w, 0], ["l", 0, h], ["l", -w, 0], ["z"]]; + }, + ellipsePath = function (x, y, rx, ry) { + if (ry == null) { + ry = rx; + } + return [["M", x, y], ["m", 0, -ry], ["a", rx, ry, 0, 1, 1, 0, 2 * ry], ["a", rx, ry, 0, 1, 1, 0, -2 * ry], ["z"]]; + }, + getPath = R._getPath = { + path: function (el) { + return el.attr("path"); + }, + circle: function (el) { + var a = el.attrs; + return ellipsePath(a.cx, a.cy, a.r); + }, + ellipse: function (el) { + var a = el.attrs; + return ellipsePath(a.cx, a.cy, a.rx, a.ry); + }, + rect: function (el) { + var a = el.attrs; + return rectPath(a.x, a.y, a.width, a.height, a.r); + }, + image: function (el) { + var a = el.attrs; + return rectPath(a.x, a.y, a.width, a.height); + }, + text: function (el) { + var bbox = el._getBBox(); + return rectPath(bbox.x, bbox.y, bbox.width, bbox.height); + }, + set : function(el) { + var bbox = el._getBBox(); + return rectPath(bbox.x, bbox.y, bbox.width, bbox.height); + } + }, + /*\ + * Raphael.mapPath + [ method ] + ** + * Transform the path string with given matrix. + > Parameters + - path (string) path string + - matrix (object) see @Matrix + = (string) transformed path string + \*/ + mapPath = R.mapPath = function (path, matrix) { + if (!matrix) { + return path; + } + var x, y, i, j, ii, jj, pathi; + path = path2curve(path); + for (i = 0, ii = path.length; i < ii; i++) { + pathi = path[i]; + for (j = 1, jj = pathi.length; j < jj; j += 2) { + x = matrix.x(pathi[j], pathi[j + 1]); + y = matrix.y(pathi[j], pathi[j + 1]); + pathi[j] = x; + pathi[j + 1] = y; + } + } + return path; + }; + + R._g = g; + /*\ + * Raphael.type + [ property (string) ] + ** + * Can be “SVG”, “VML” or empty, depending on browser support. + \*/ + R.type = (g.win.SVGAngle || g.doc.implementation.hasFeature("http://www.w3.org/TR/SVG11/feature#BasicStructure", "1.1") ? "SVG" : "VML"); + if (R.type == "VML") { + var d = g.doc.createElement("div"), + b; + d.innerHTML = ''; + b = d.firstChild; + b.style.behavior = "url(#default#VML)"; + if (!(b && typeof b.adj == "object")) { + return (R.type = E); + } + d = null; + } + /*\ + * Raphael.svg + [ property (boolean) ] + ** + * `true` if browser supports SVG. + \*/ + /*\ + * Raphael.vml + [ property (boolean) ] + ** + * `true` if browser supports VML. + \*/ + R.svg = !(R.vml = R.type == "VML"); + R._Paper = Paper; + /*\ + * Raphael.fn + [ property (object) ] + ** + * You can add your own method to the canvas. For example if you want to draw a pie chart, + * you can create your own pie chart function and ship it as a Raphaël plugin. To do this + * you need to extend the `Raphael.fn` object. You should modify the `fn` object before a + * Raphaël instance is created, otherwise it will take no effect. Please note that the + * ability for namespaced plugins was removed in Raphael 2.0. It is up to the plugin to + * ensure any namespacing ensures proper context. + > Usage + | Raphael.fn.arrow = function (x1, y1, x2, y2, size) { + | return this.path( ... ); + | }; + | // or create namespace + | Raphael.fn.mystuff = { + | arrow: function () {…}, + | star: function () {…}, + | // etc… + | }; + | var paper = Raphael(10, 10, 630, 480); + | // then use it + | paper.arrow(10, 10, 30, 30, 5).attr({fill: "#f00"}); + | paper.mystuff.arrow(); + | paper.mystuff.star(); + \*/ + R.fn = paperproto = Paper.prototype = R.prototype; + R._id = 0; + R._oid = 0; + /*\ + * Raphael.is + [ method ] + ** + * Handful of replacements for `typeof` operator. + > Parameters + - o (…) any object or primitive + - type (string) name of the type, i.e. “string”, “function”, “number”, etc. + = (boolean) is given value is of given type + \*/ + R.is = function (o, type) { + type = lowerCase.call(type); + if (type == "finite") { + return !isnan[has](+o); + } + if (type == "array") { + return o instanceof Array; + } + return (type == "null" && o === null) || + (type == typeof o && o !== null) || + (type == "object" && o === Object(o)) || + (type == "array" && Array.isArray && Array.isArray(o)) || + objectToString.call(o).slice(8, -1).toLowerCase() == type; + }; + + function clone(obj) { + if (typeof obj == "function" || Object(obj) !== obj) { + return obj; + } + var res = new obj.constructor; + for (var key in obj) if (obj[has](key)) { + res[key] = clone(obj[key]); + } + return res; + } + + /*\ + * Raphael.angle + [ method ] + ** + * Returns angle between two or three points + > Parameters + - x1 (number) x coord of first point + - y1 (number) y coord of first point + - x2 (number) x coord of second point + - y2 (number) y coord of second point + - x3 (number) #optional x coord of third point + - y3 (number) #optional y coord of third point + = (number) angle in degrees. + \*/ + R.angle = function (x1, y1, x2, y2, x3, y3) { + if (x3 == null) { + var x = x1 - x2, + y = y1 - y2; + if (!x && !y) { + return 0; + } + return (180 + math.atan2(-y, -x) * 180 / PI + 360) % 360; + } else { + return R.angle(x1, y1, x3, y3) - R.angle(x2, y2, x3, y3); + } + }; + /*\ + * Raphael.rad + [ method ] + ** + * Transform angle to radians + > Parameters + - deg (number) angle in degrees + = (number) angle in radians. + \*/ + R.rad = function (deg) { + return deg % 360 * PI / 180; + }; + /*\ + * Raphael.deg + [ method ] + ** + * Transform angle to degrees + > Parameters + - rad (number) angle in radians + = (number) angle in degrees. + \*/ + R.deg = function (rad) { + return Math.round ((rad * 180 / PI% 360)* 1000) / 1000; + }; + /*\ + * Raphael.snapTo + [ method ] + ** + * Snaps given value to given grid. + > Parameters + - values (array|number) given array of values or step of the grid + - value (number) value to adjust + - tolerance (number) #optional tolerance for snapping. Default is `10`. + = (number) adjusted value. + \*/ + R.snapTo = function (values, value, tolerance) { + tolerance = R.is(tolerance, "finite") ? tolerance : 10; + if (R.is(values, array)) { + var i = values.length; + while (i--) if (abs(values[i] - value) <= tolerance) { + return values[i]; + } + } else { + values = +values; + var rem = value % values; + if (rem < tolerance) { + return value - rem; + } + if (rem > values - tolerance) { + return value - rem + values; + } + } + return value; + }; + + /*\ + * Raphael.createUUID + [ method ] + ** + * Returns RFC4122, version 4 ID + \*/ + var createUUID = R.createUUID = (function (uuidRegEx, uuidReplacer) { + return function () { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(uuidRegEx, uuidReplacer).toUpperCase(); + }; + })(/[xy]/g, function (c) { + var r = math.random() * 16 | 0, + v = c == "x" ? r : (r & 3 | 8); + return v.toString(16); + }); + + /*\ + * Raphael.setWindow + [ method ] + ** + * Used when you need to draw in `<iframe>`. Switched window to the iframe one. + > Parameters + - newwin (window) new window object + \*/ + R.setWindow = function (newwin) { + eve("raphael.setWindow", R, g.win, newwin); + g.win = newwin; + g.doc = g.win.document; + if (R._engine.initWin) { + R._engine.initWin(g.win); + } + }; + var toHex = function (color) { + if (R.vml) { + // http://dean.edwards.name/weblog/2009/10/convert-any-colour-value-to-hex-in-msie/ + var trim = /^\s+|\s+$/g; + var bod; + try { + var docum = new ActiveXObject("htmlfile"); + docum.write(""); + docum.close(); + bod = docum.body; + } catch(e) { + bod = createPopup().document.body; + } + var range = bod.createTextRange(); + toHex = cacher(function (color) { + try { + bod.style.color = Str(color).replace(trim, E); + var value = range.queryCommandValue("ForeColor"); + value = ((value & 255) << 16) | (value & 65280) | ((value & 16711680) >>> 16); + return "#" + ("000000" + value.toString(16)).slice(-6); + } catch(e) { + return "none"; + } + }); + } else { + var i = g.doc.createElement("i"); + i.title = "Rapha\xebl Colour Picker"; + i.style.display = "none"; + g.doc.body.appendChild(i); + toHex = cacher(function (color) { + i.style.color = color; + return g.doc.defaultView.getComputedStyle(i, E).getPropertyValue("color"); + }); + } + return toHex(color); + }, + hsbtoString = function () { + return "hsb(" + [this.h, this.s, this.b] + ")"; + }, + hsltoString = function () { + return "hsl(" + [this.h, this.s, this.l] + ")"; + }, + rgbtoString = function () { + return this.hex; + }, + prepareRGB = function (r, g, b) { + if (g == null && R.is(r, "object") && "r" in r && "g" in r && "b" in r) { + b = r.b; + g = r.g; + r = r.r; + } + if (g == null && R.is(r, string)) { + var clr = R.getRGB(r); + r = clr.r; + g = clr.g; + b = clr.b; + } + if (r > 1 || g > 1 || b > 1) { + r /= 255; + g /= 255; + b /= 255; + } + + return [r, g, b]; + }, + packageRGB = function (r, g, b, o) { + r *= 255; + g *= 255; + b *= 255; + var rgb = { + r: r, + g: g, + b: b, + hex: R.rgb(r, g, b), + toString: rgbtoString + }; + R.is(o, "finite") && (rgb.opacity = o); + return rgb; + }; + + /*\ + * Raphael.color + [ method ] + ** + * Parses the color string and returns object with all values for the given color. + > Parameters + - clr (string) color string in one of the supported formats (see @Raphael.getRGB) + = (object) Combined RGB & HSB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue, + o hex (string) color in HTML/CSS format: #••••••, + o error (boolean) `true` if string can’t be parsed, + o h (number) hue, + o s (number) saturation, + o v (number) value (brightness), + o l (number) lightness + o } + \*/ + R.color = function (clr) { + var rgb; + if (R.is(clr, "object") && "h" in clr && "s" in clr && "b" in clr) { + rgb = R.hsb2rgb(clr); + clr.r = rgb.r; + clr.g = rgb.g; + clr.b = rgb.b; + clr.hex = rgb.hex; + } else if (R.is(clr, "object") && "h" in clr && "s" in clr && "l" in clr) { + rgb = R.hsl2rgb(clr); + clr.r = rgb.r; + clr.g = rgb.g; + clr.b = rgb.b; + clr.hex = rgb.hex; + } else { + if (R.is(clr, "string")) { + clr = R.getRGB(clr); + } + if (R.is(clr, "object") && "r" in clr && "g" in clr && "b" in clr) { + rgb = R.rgb2hsl(clr); + clr.h = rgb.h; + clr.s = rgb.s; + clr.l = rgb.l; + rgb = R.rgb2hsb(clr); + clr.v = rgb.b; + } else { + clr = {hex: "none"}; + clr.r = clr.g = clr.b = clr.h = clr.s = clr.v = clr.l = -1; + } + } + clr.toString = rgbtoString; + return clr; + }; + /*\ + * Raphael.hsb2rgb + [ method ] + ** + * Converts HSB values to RGB object. + > Parameters + - h (number) hue + - s (number) saturation + - v (number) value or brightness + = (object) RGB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue, + o hex (string) color in HTML/CSS format: #•••••• + o } + \*/ + R.hsb2rgb = function (h, s, v, o) { + if (this.is(h, "object") && "h" in h && "s" in h && "b" in h) { + v = h.b; + s = h.s; + o = h.o; + h = h.h; + } + h *= 360; + var R, G, B, X, C; + h = (h % 360) / 60; + C = v * s; + X = C * (1 - abs(h % 2 - 1)); + R = G = B = v - C; + + h = ~~h; + R += [C, X, 0, 0, X, C][h]; + G += [X, C, C, X, 0, 0][h]; + B += [0, 0, X, C, C, X][h]; + return packageRGB(R, G, B, o); + }; + /*\ + * Raphael.hsl2rgb + [ method ] + ** + * Converts HSL values to RGB object. + > Parameters + - h (number) hue + - s (number) saturation + - l (number) luminosity + = (object) RGB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue, + o hex (string) color in HTML/CSS format: #•••••• + o } + \*/ + R.hsl2rgb = function (h, s, l, o) { + if (this.is(h, "object") && "h" in h && "s" in h && "l" in h) { + l = h.l; + s = h.s; + h = h.h; + } + if (h > 1 || s > 1 || l > 1) { + h /= 360; + s /= 100; + l /= 100; + } + h *= 360; + var R, G, B, X, C; + h = (h % 360) / 60; + C = 2 * s * (l < .5 ? l : 1 - l); + X = C * (1 - abs(h % 2 - 1)); + R = G = B = l - C / 2; + + h = ~~h; + R += [C, X, 0, 0, X, C][h]; + G += [X, C, C, X, 0, 0][h]; + B += [0, 0, X, C, C, X][h]; + return packageRGB(R, G, B, o); + }; + /*\ + * Raphael.rgb2hsb + [ method ] + ** + * Converts RGB values to HSB object. + > Parameters + - r (number) red + - g (number) green + - b (number) blue + = (object) HSB object in format: + o { + o h (number) hue + o s (number) saturation + o b (number) brightness + o } + \*/ + R.rgb2hsb = function (r, g, b) { + b = prepareRGB(r, g, b); + r = b[0]; + g = b[1]; + b = b[2]; + + var H, S, V, C; + V = mmax(r, g, b); + C = V - mmin(r, g, b); + H = (C == 0 ? null : + V == r ? (g - b) / C : + V == g ? (b - r) / C + 2 : + (r - g) / C + 4 + ); + H = ((H + 360) % 6) * 60 / 360; + S = C == 0 ? 0 : C / V; + return {h: H, s: S, b: V, toString: hsbtoString}; + }; + /*\ + * Raphael.rgb2hsl + [ method ] + ** + * Converts RGB values to HSL object. + > Parameters + - r (number) red + - g (number) green + - b (number) blue + = (object) HSL object in format: + o { + o h (number) hue + o s (number) saturation + o l (number) luminosity + o } + \*/ + R.rgb2hsl = function (r, g, b) { + b = prepareRGB(r, g, b); + r = b[0]; + g = b[1]; + b = b[2]; + + var H, S, L, M, m, C; + M = mmax(r, g, b); + m = mmin(r, g, b); + C = M - m; + H = (C == 0 ? null : + M == r ? (g - b) / C : + M == g ? (b - r) / C + 2 : + (r - g) / C + 4); + H = ((H + 360) % 6) * 60 / 360; + L = (M + m) / 2; + S = (C == 0 ? 0 : + L < .5 ? C / (2 * L) : + C / (2 - 2 * L)); + return {h: H, s: S, l: L, toString: hsltoString}; + }; + R._path2string = function () { + return this.join(",").replace(p2s, "$1"); + }; + function repush(array, item) { + for (var i = 0, ii = array.length; i < ii; i++) if (array[i] === item) { + return array.push(array.splice(i, 1)[0]); + } + } + function cacher(f, scope, postprocessor) { + function newf() { + var arg = Array.prototype.slice.call(arguments, 0), + args = arg.join("\u2400"), + cache = newf.cache = newf.cache || {}, + count = newf.count = newf.count || []; + if (cache[has](args)) { + repush(count, args); + return postprocessor ? postprocessor(cache[args]) : cache[args]; + } + count.length >= 1e3 && delete cache[count.shift()]; + count.push(args); + cache[args] = f[apply](scope, arg); + return postprocessor ? postprocessor(cache[args]) : cache[args]; + } + return newf; + } + + var preload = R._preload = function (src, f) { + var img = g.doc.createElement("img"); + img.style.cssText = "position:absolute;left:-9999em;top:-9999em"; + img.onload = function () { + f.call(this); + this.onload = null; + g.doc.body.removeChild(this); + }; + img.onerror = function () { + g.doc.body.removeChild(this); + }; + g.doc.body.appendChild(img); + img.src = src; + }; + + function clrToString() { + return this.hex; + } + + /*\ + * Raphael.getRGB + [ method ] + ** + * Parses colour string as RGB object + > Parameters + - colour (string) colour string in one of formats: + #
      + #
    • Colour name (“red”, “green”, “cornflowerblue”, etc)
    • + #
    • #••• — shortened HTML colour: (“#000”, “#fc0”, etc)
    • + #
    • #•••••• — full length HTML colour: (“#000000”, “#bd2300”)
    • + #
    • rgb(•••, •••, •••) — red, green and blue channels’ values: (“rgb(200, 100, 0)”)
    • + #
    • rgb(•••%, •••%, •••%) — same as above, but in %: (“rgb(100%, 175%, 0%)”)
    • + #
    • hsb(•••, •••, •••) — hue, saturation and brightness values: (“hsb(0.5, 0.25, 1)”)
    • + #
    • hsb(•••%, •••%, •••%) — same as above, but in %
    • + #
    • hsl(•••, •••, •••) — same as hsb
    • + #
    • hsl(•••%, •••%, •••%) — same as hsb
    • + #
    + = (object) RGB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue + o hex (string) color in HTML/CSS format: #••••••, + o error (boolean) true if string can’t be parsed + o } + \*/ + R.getRGB = cacher(function (colour) { + if (!colour || !!((colour = Str(colour)).indexOf("-") + 1)) { + return {r: -1, g: -1, b: -1, hex: "none", error: 1, toString: clrToString}; + } + if (colour == "none") { + return {r: -1, g: -1, b: -1, hex: "none", toString: clrToString}; + } + !(hsrg[has](colour.toLowerCase().substring(0, 2)) || colour.charAt() == "#") && (colour = toHex(colour)); + var res, + red, + green, + blue, + opacity, + t, + values, + rgb = colour.match(colourRegExp); + if (rgb) { + if (rgb[2]) { + blue = toInt(rgb[2].substring(5), 16); + green = toInt(rgb[2].substring(3, 5), 16); + red = toInt(rgb[2].substring(1, 3), 16); + } + if (rgb[3]) { + blue = toInt((t = rgb[3].charAt(3)) + t, 16); + green = toInt((t = rgb[3].charAt(2)) + t, 16); + red = toInt((t = rgb[3].charAt(1)) + t, 16); + } + if (rgb[4]) { + values = rgb[4][split](commaSpaces); + red = toFloat(values[0]); + values[0].slice(-1) == "%" && (red *= 2.55); + green = toFloat(values[1]); + values[1].slice(-1) == "%" && (green *= 2.55); + blue = toFloat(values[2]); + values[2].slice(-1) == "%" && (blue *= 2.55); + rgb[1].toLowerCase().slice(0, 4) == "rgba" && (opacity = toFloat(values[3])); + values[3] && values[3].slice(-1) == "%" && (opacity /= 100); + } + if (rgb[5]) { + values = rgb[5][split](commaSpaces); + red = toFloat(values[0]); + values[0].slice(-1) == "%" && (red *= 2.55); + green = toFloat(values[1]); + values[1].slice(-1) == "%" && (green *= 2.55); + blue = toFloat(values[2]); + values[2].slice(-1) == "%" && (blue *= 2.55); + (values[0].slice(-3) == "deg" || values[0].slice(-1) == "\xb0") && (red /= 360); + rgb[1].toLowerCase().slice(0, 4) == "hsba" && (opacity = toFloat(values[3])); + values[3] && values[3].slice(-1) == "%" && (opacity /= 100); + return R.hsb2rgb(red, green, blue, opacity); + } + if (rgb[6]) { + values = rgb[6][split](commaSpaces); + red = toFloat(values[0]); + values[0].slice(-1) == "%" && (red *= 2.55); + green = toFloat(values[1]); + values[1].slice(-1) == "%" && (green *= 2.55); + blue = toFloat(values[2]); + values[2].slice(-1) == "%" && (blue *= 2.55); + (values[0].slice(-3) == "deg" || values[0].slice(-1) == "\xb0") && (red /= 360); + rgb[1].toLowerCase().slice(0, 4) == "hsla" && (opacity = toFloat(values[3])); + values[3] && values[3].slice(-1) == "%" && (opacity /= 100); + return R.hsl2rgb(red, green, blue, opacity); + } + rgb = {r: red, g: green, b: blue, toString: clrToString}; + rgb.hex = "#" + (16777216 | blue | (green << 8) | (red << 16)).toString(16).slice(1); + R.is(opacity, "finite") && (rgb.opacity = opacity); + return rgb; + } + return {r: -1, g: -1, b: -1, hex: "none", error: 1, toString: clrToString}; + }, R); + /*\ + * Raphael.hsb + [ method ] + ** + * Converts HSB values to hex representation of the colour. + > Parameters + - h (number) hue + - s (number) saturation + - b (number) value or brightness + = (string) hex representation of the colour. + \*/ + R.hsb = cacher(function (h, s, b) { + return R.hsb2rgb(h, s, b).hex; + }); + /*\ + * Raphael.hsl + [ method ] + ** + * Converts HSL values to hex representation of the colour. + > Parameters + - h (number) hue + - s (number) saturation + - l (number) luminosity + = (string) hex representation of the colour. + \*/ + R.hsl = cacher(function (h, s, l) { + return R.hsl2rgb(h, s, l).hex; + }); + /*\ + * Raphael.rgb + [ method ] + ** + * Converts RGB values to hex representation of the colour. + > Parameters + - r (number) red + - g (number) green + - b (number) blue + = (string) hex representation of the colour. + \*/ + R.rgb = cacher(function (r, g, b) { + return "#" + (16777216 | b | (g << 8) | (r << 16)).toString(16).slice(1); + }); + /*\ + * Raphael.getColor + [ method ] + ** + * On each call returns next colour in the spectrum. To reset it back to red call @Raphael.getColor.reset + > Parameters + - value (number) #optional brightness, default is `0.75` + = (string) hex representation of the colour. + \*/ + R.getColor = function (value) { + var start = this.getColor.start = this.getColor.start || {h: 0, s: 1, b: value || .75}, + rgb = this.hsb2rgb(start.h, start.s, start.b); + start.h += .075; + if (start.h > 1) { + start.h = 0; + start.s -= .2; + start.s <= 0 && (this.getColor.start = {h: 0, s: 1, b: start.b}); + } + return rgb.hex; + }; + /*\ + * Raphael.getColor.reset + [ method ] + ** + * Resets spectrum position for @Raphael.getColor back to red. + \*/ + R.getColor.reset = function () { + delete this.start; + }; + + // http://schepers.cc/getting-to-the-point + function catmullRom2bezier(crp, z) { + var d = []; + for (var i = 0, iLen = crp.length; iLen - 2 * !z > i; i += 2) { + var p = [ + {x: +crp[i - 2], y: +crp[i - 1]}, + {x: +crp[i], y: +crp[i + 1]}, + {x: +crp[i + 2], y: +crp[i + 3]}, + {x: +crp[i + 4], y: +crp[i + 5]} + ]; + if (z) { + if (!i) { + p[0] = {x: +crp[iLen - 2], y: +crp[iLen - 1]}; + } else if (iLen - 4 == i) { + p[3] = {x: +crp[0], y: +crp[1]}; + } else if (iLen - 2 == i) { + p[2] = {x: +crp[0], y: +crp[1]}; + p[3] = {x: +crp[2], y: +crp[3]}; + } + } else { + if (iLen - 4 == i) { + p[3] = p[2]; + } else if (!i) { + p[0] = {x: +crp[i], y: +crp[i + 1]}; + } + } + d.push(["C", + (-p[0].x + 6 * p[1].x + p[2].x) / 6, + (-p[0].y + 6 * p[1].y + p[2].y) / 6, + (p[1].x + 6 * p[2].x - p[3].x) / 6, + (p[1].y + 6*p[2].y - p[3].y) / 6, + p[2].x, + p[2].y + ]); + } + + return d; + } + /*\ + * Raphael.parsePathString + [ method ] + ** + * Utility method + ** + * Parses given path string into an array of arrays of path segments. + > Parameters + - pathString (string|array) path string or array of segments (in the last case it will be returned straight away) + = (array) array of segments. + \*/ + R.parsePathString = function (pathString) { + if (!pathString) { + return null; + } + var pth = paths(pathString); + if (pth.arr) { + return pathClone(pth.arr); + } + + var paramCounts = {a: 7, c: 6, h: 1, l: 2, m: 2, r: 4, q: 4, s: 4, t: 2, v: 1, z: 0}, + data = []; + if (R.is(pathString, array) && R.is(pathString[0], array)) { // rough assumption + data = pathClone(pathString); + } + if (!data.length) { + Str(pathString).replace(pathCommand, function (a, b, c) { + var params = [], + name = b.toLowerCase(); + c.replace(pathValues, function (a, b) { + b && params.push(+b); + }); + if (name == "m" && params.length > 2) { + data.push([b][concat](params.splice(0, 2))); + name = "l"; + b = b == "m" ? "l" : "L"; + } + if (name == "r") { + data.push([b][concat](params)); + } else while (params.length >= paramCounts[name]) { + data.push([b][concat](params.splice(0, paramCounts[name]))); + if (!paramCounts[name]) { + break; + } + } + }); + } + data.toString = R._path2string; + pth.arr = pathClone(data); + return data; + }; + /*\ + * Raphael.parseTransformString + [ method ] + ** + * Utility method + ** + * Parses given path string into an array of transformations. + > Parameters + - TString (string|array) transform string or array of transformations (in the last case it will be returned straight away) + = (array) array of transformations. + \*/ + R.parseTransformString = cacher(function (TString) { + if (!TString) { + return null; + } + var paramCounts = {r: 3, s: 4, t: 2, m: 6}, + data = []; + if (R.is(TString, array) && R.is(TString[0], array)) { // rough assumption + data = pathClone(TString); + } + if (!data.length) { + Str(TString).replace(tCommand, function (a, b, c) { + var params = [], + name = lowerCase.call(b); + c.replace(pathValues, function (a, b) { + b && params.push(+b); + }); + data.push([b][concat](params)); + }); + } + data.toString = R._path2string; + return data; + }); + // PATHS + var paths = function (ps) { + var p = paths.ps = paths.ps || {}; + if (p[ps]) { + p[ps].sleep = 100; + } else { + p[ps] = { + sleep: 100 + }; + } + setTimeout(function () { + for (var key in p) if (p[has](key) && key != ps) { + p[key].sleep--; + !p[key].sleep && delete p[key]; + } + }); + return p[ps]; + }; + /*\ + * Raphael.findDotsAtSegment + [ method ] + ** + * Utility method + ** + * Find dot coordinates on the given cubic bezier curve at the given t. + > Parameters + - p1x (number) x of the first point of the curve + - p1y (number) y of the first point of the curve + - c1x (number) x of the first anchor of the curve + - c1y (number) y of the first anchor of the curve + - c2x (number) x of the second anchor of the curve + - c2y (number) y of the second anchor of the curve + - p2x (number) x of the second point of the curve + - p2y (number) y of the second point of the curve + - t (number) position on the curve (0..1) + = (object) point information in format: + o { + o x: (number) x coordinate of the point + o y: (number) y coordinate of the point + o m: { + o x: (number) x coordinate of the left anchor + o y: (number) y coordinate of the left anchor + o } + o n: { + o x: (number) x coordinate of the right anchor + o y: (number) y coordinate of the right anchor + o } + o start: { + o x: (number) x coordinate of the start of the curve + o y: (number) y coordinate of the start of the curve + o } + o end: { + o x: (number) x coordinate of the end of the curve + o y: (number) y coordinate of the end of the curve + o } + o alpha: (number) angle of the curve derivative at the point + o } + \*/ + R.findDotsAtSegment = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t) { + var t1 = 1 - t, + t13 = pow(t1, 3), + t12 = pow(t1, 2), + t2 = t * t, + t3 = t2 * t, + x = t13 * p1x + t12 * 3 * t * c1x + t1 * 3 * t * t * c2x + t3 * p2x, + y = t13 * p1y + t12 * 3 * t * c1y + t1 * 3 * t * t * c2y + t3 * p2y, + mx = p1x + 2 * t * (c1x - p1x) + t2 * (c2x - 2 * c1x + p1x), + my = p1y + 2 * t * (c1y - p1y) + t2 * (c2y - 2 * c1y + p1y), + nx = c1x + 2 * t * (c2x - c1x) + t2 * (p2x - 2 * c2x + c1x), + ny = c1y + 2 * t * (c2y - c1y) + t2 * (p2y - 2 * c2y + c1y), + ax = t1 * p1x + t * c1x, + ay = t1 * p1y + t * c1y, + cx = t1 * c2x + t * p2x, + cy = t1 * c2y + t * p2y, + alpha = (90 - math.atan2(mx - nx, my - ny) * 180 / PI); + (mx > nx || my < ny) && (alpha += 180); + return { + x: x, + y: y, + m: {x: mx, y: my}, + n: {x: nx, y: ny}, + start: {x: ax, y: ay}, + end: {x: cx, y: cy}, + alpha: alpha + }; + }; + /*\ + * Raphael.bezierBBox + [ method ] + ** + * Utility method + ** + * Return bounding box of a given cubic bezier curve + > Parameters + - p1x (number) x of the first point of the curve + - p1y (number) y of the first point of the curve + - c1x (number) x of the first anchor of the curve + - c1y (number) y of the first anchor of the curve + - c2x (number) x of the second anchor of the curve + - c2y (number) y of the second anchor of the curve + - p2x (number) x of the second point of the curve + - p2y (number) y of the second point of the curve + * or + - bez (array) array of six points for bezier curve + = (object) point information in format: + o { + o min: { + o x: (number) x coordinate of the left point + o y: (number) y coordinate of the top point + o } + o max: { + o x: (number) x coordinate of the right point + o y: (number) y coordinate of the bottom point + o } + o } + \*/ + R.bezierBBox = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y) { + if (!R.is(p1x, "array")) { + p1x = [p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y]; + } + var bbox = curveDim.apply(null, p1x); + return { + x: bbox.min.x, + y: bbox.min.y, + x2: bbox.max.x, + y2: bbox.max.y, + width: bbox.max.x - bbox.min.x, + height: bbox.max.y - bbox.min.y + }; + }; + /*\ + * Raphael.isPointInsideBBox + [ method ] + ** + * Utility method + ** + * Returns `true` if given point is inside bounding boxes. + > Parameters + - bbox (string) bounding box + - x (string) x coordinate of the point + - y (string) y coordinate of the point + = (boolean) `true` if point inside + \*/ + R.isPointInsideBBox = function (bbox, x, y) { + return x >= bbox.x && x <= bbox.x2 && y >= bbox.y && y <= bbox.y2; + }; + /*\ + * Raphael.isBBoxIntersect + [ method ] + ** + * Utility method + ** + * Returns `true` if two bounding boxes intersect + > Parameters + - bbox1 (string) first bounding box + - bbox2 (string) second bounding box + = (boolean) `true` if they intersect + \*/ + R.isBBoxIntersect = function (bbox1, bbox2) { + var i = R.isPointInsideBBox; + return i(bbox2, bbox1.x, bbox1.y) + || i(bbox2, bbox1.x2, bbox1.y) + || i(bbox2, bbox1.x, bbox1.y2) + || i(bbox2, bbox1.x2, bbox1.y2) + || i(bbox1, bbox2.x, bbox2.y) + || i(bbox1, bbox2.x2, bbox2.y) + || i(bbox1, bbox2.x, bbox2.y2) + || i(bbox1, bbox2.x2, bbox2.y2) + || (bbox1.x < bbox2.x2 && bbox1.x > bbox2.x || bbox2.x < bbox1.x2 && bbox2.x > bbox1.x) + && (bbox1.y < bbox2.y2 && bbox1.y > bbox2.y || bbox2.y < bbox1.y2 && bbox2.y > bbox1.y); + }; + function base3(t, p1, p2, p3, p4) { + var t1 = -3 * p1 + 9 * p2 - 9 * p3 + 3 * p4, + t2 = t * t1 + 6 * p1 - 12 * p2 + 6 * p3; + return t * t2 - 3 * p1 + 3 * p2; + } + function bezlen(x1, y1, x2, y2, x3, y3, x4, y4, z) { + if (z == null) { + z = 1; + } + z = z > 1 ? 1 : z < 0 ? 0 : z; + var z2 = z / 2, + n = 12, + Tvalues = [-0.1252,0.1252,-0.3678,0.3678,-0.5873,0.5873,-0.7699,0.7699,-0.9041,0.9041,-0.9816,0.9816], + Cvalues = [0.2491,0.2491,0.2335,0.2335,0.2032,0.2032,0.1601,0.1601,0.1069,0.1069,0.0472,0.0472], + sum = 0; + for (var i = 0; i < n; i++) { + var ct = z2 * Tvalues[i] + z2, + xbase = base3(ct, x1, x2, x3, x4), + ybase = base3(ct, y1, y2, y3, y4), + comb = xbase * xbase + ybase * ybase; + sum += Cvalues[i] * math.sqrt(comb); + } + return z2 * sum; + } + function getTatLen(x1, y1, x2, y2, x3, y3, x4, y4, ll) { + if (ll < 0 || bezlen(x1, y1, x2, y2, x3, y3, x4, y4) < ll) { + return; + } + var t = 1, + step = t / 2, + t2 = t - step, + l, + e = .01; + l = bezlen(x1, y1, x2, y2, x3, y3, x4, y4, t2); + while (abs(l - ll) > e) { + step /= 2; + t2 += (l < ll ? 1 : -1) * step; + l = bezlen(x1, y1, x2, y2, x3, y3, x4, y4, t2); + } + return t2; + } + function intersect(x1, y1, x2, y2, x3, y3, x4, y4) { + if ( + mmax(x1, x2) < mmin(x3, x4) || + mmin(x1, x2) > mmax(x3, x4) || + mmax(y1, y2) < mmin(y3, y4) || + mmin(y1, y2) > mmax(y3, y4) + ) { + return; + } + var nx = (x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4), + ny = (x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4), + denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + + if (!denominator) { + return; + } + var px = nx / denominator, + py = ny / denominator, + px2 = +px.toFixed(2), + py2 = +py.toFixed(2); + if ( + px2 < +mmin(x1, x2).toFixed(2) || + px2 > +mmax(x1, x2).toFixed(2) || + px2 < +mmin(x3, x4).toFixed(2) || + px2 > +mmax(x3, x4).toFixed(2) || + py2 < +mmin(y1, y2).toFixed(2) || + py2 > +mmax(y1, y2).toFixed(2) || + py2 < +mmin(y3, y4).toFixed(2) || + py2 > +mmax(y3, y4).toFixed(2) + ) { + return; + } + return {x: px, y: py}; + } + function inter(bez1, bez2) { + return interHelper(bez1, bez2); + } + function interCount(bez1, bez2) { + return interHelper(bez1, bez2, 1); + } + function interHelper(bez1, bez2, justCount) { + var bbox1 = R.bezierBBox(bez1), + bbox2 = R.bezierBBox(bez2); + if (!R.isBBoxIntersect(bbox1, bbox2)) { + return justCount ? 0 : []; + } + var l1 = bezlen.apply(0, bez1), + l2 = bezlen.apply(0, bez2), + n1 = mmax(~~(l1 / 5), 1), + n2 = mmax(~~(l2 / 5), 1), + dots1 = [], + dots2 = [], + xy = {}, + res = justCount ? 0 : []; + for (var i = 0; i < n1 + 1; i++) { + var p = R.findDotsAtSegment.apply(R, bez1.concat(i / n1)); + dots1.push({x: p.x, y: p.y, t: i / n1}); + } + for (i = 0; i < n2 + 1; i++) { + p = R.findDotsAtSegment.apply(R, bez2.concat(i / n2)); + dots2.push({x: p.x, y: p.y, t: i / n2}); + } + for (i = 0; i < n1; i++) { + for (var j = 0; j < n2; j++) { + var di = dots1[i], + di1 = dots1[i + 1], + dj = dots2[j], + dj1 = dots2[j + 1], + ci = abs(di1.x - di.x) < .001 ? "y" : "x", + cj = abs(dj1.x - dj.x) < .001 ? "y" : "x", + is = intersect(di.x, di.y, di1.x, di1.y, dj.x, dj.y, dj1.x, dj1.y); + if (is) { + if (xy[is.x.toFixed(4)] == is.y.toFixed(4)) { + continue; + } + xy[is.x.toFixed(4)] = is.y.toFixed(4); + var t1 = di.t + abs((is[ci] - di[ci]) / (di1[ci] - di[ci])) * (di1.t - di.t), + t2 = dj.t + abs((is[cj] - dj[cj]) / (dj1[cj] - dj[cj])) * (dj1.t - dj.t); + if (t1 >= 0 && t1 <= 1.001 && t2 >= 0 && t2 <= 1.001) { + if (justCount) { + res++; + } else { + res.push({ + x: is.x, + y: is.y, + t1: mmin(t1, 1), + t2: mmin(t2, 1) + }); + } + } + } + } + } + return res; + } + /*\ + * Raphael.pathIntersection + [ method ] + ** + * Utility method + ** + * Finds intersections of two paths + > Parameters + - path1 (string) path string + - path2 (string) path string + = (array) dots of intersection + o [ + o { + o x: (number) x coordinate of the point + o y: (number) y coordinate of the point + o t1: (number) t value for segment of path1 + o t2: (number) t value for segment of path2 + o segment1: (number) order number for segment of path1 + o segment2: (number) order number for segment of path2 + o bez1: (array) eight coordinates representing beziér curve for the segment of path1 + o bez2: (array) eight coordinates representing beziér curve for the segment of path2 + o } + o ] + \*/ + R.pathIntersection = function (path1, path2) { + return interPathHelper(path1, path2); + }; + R.pathIntersectionNumber = function (path1, path2) { + return interPathHelper(path1, path2, 1); + }; + function interPathHelper(path1, path2, justCount) { + path1 = R._path2curve(path1); + path2 = R._path2curve(path2); + var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2, + res = justCount ? 0 : []; + for (var i = 0, ii = path1.length; i < ii; i++) { + var pi = path1[i]; + if (pi[0] == "M") { + x1 = x1m = pi[1]; + y1 = y1m = pi[2]; + } else { + if (pi[0] == "C") { + bez1 = [x1, y1].concat(pi.slice(1)); + x1 = bez1[6]; + y1 = bez1[7]; + } else { + bez1 = [x1, y1, x1, y1, x1m, y1m, x1m, y1m]; + x1 = x1m; + y1 = y1m; + } + for (var j = 0, jj = path2.length; j < jj; j++) { + var pj = path2[j]; + if (pj[0] == "M") { + x2 = x2m = pj[1]; + y2 = y2m = pj[2]; + } else { + if (pj[0] == "C") { + bez2 = [x2, y2].concat(pj.slice(1)); + x2 = bez2[6]; + y2 = bez2[7]; + } else { + bez2 = [x2, y2, x2, y2, x2m, y2m, x2m, y2m]; + x2 = x2m; + y2 = y2m; + } + var intr = interHelper(bez1, bez2, justCount); + if (justCount) { + res += intr; + } else { + for (var k = 0, kk = intr.length; k < kk; k++) { + intr[k].segment1 = i; + intr[k].segment2 = j; + intr[k].bez1 = bez1; + intr[k].bez2 = bez2; + } + res = res.concat(intr); + } + } + } + } + } + return res; + } + /*\ + * Raphael.isPointInsidePath + [ method ] + ** + * Utility method + ** + * Returns `true` if given point is inside a given closed path. + > Parameters + - path (string) path string + - x (number) x of the point + - y (number) y of the point + = (boolean) true, if point is inside the path + \*/ + R.isPointInsidePath = function (path, x, y) { + var bbox = R.pathBBox(path); + return R.isPointInsideBBox(bbox, x, y) && + interPathHelper(path, [["M", x, y], ["H", bbox.x2 + 10]], 1) % 2 == 1; + }; + R._removedFactory = function (methodname) { + return function () { + eve("raphael.log", null, "Rapha\xebl: you are calling to method \u201c" + methodname + "\u201d of removed object", methodname); + }; + }; + /*\ + * Raphael.pathBBox + [ method ] + ** + * Utility method + ** + * Return bounding box of a given path + > Parameters + - path (string) path string + = (object) bounding box + o { + o x: (number) x coordinate of the left top point of the box + o y: (number) y coordinate of the left top point of the box + o x2: (number) x coordinate of the right bottom point of the box + o y2: (number) y coordinate of the right bottom point of the box + o width: (number) width of the box + o height: (number) height of the box + o cx: (number) x coordinate of the center of the box + o cy: (number) y coordinate of the center of the box + o } + \*/ + var pathDimensions = R.pathBBox = function (path) { + var pth = paths(path); + if (pth.bbox) { + return clone(pth.bbox); + } + if (!path) { + return {x: 0, y: 0, width: 0, height: 0, x2: 0, y2: 0}; + } + path = path2curve(path); + var x = 0, + y = 0, + X = [], + Y = [], + p; + for (var i = 0, ii = path.length; i < ii; i++) { + p = path[i]; + if (p[0] == "M") { + x = p[1]; + y = p[2]; + X.push(x); + Y.push(y); + } else { + var dim = curveDim(x, y, p[1], p[2], p[3], p[4], p[5], p[6]); + X = X[concat](dim.min.x, dim.max.x); + Y = Y[concat](dim.min.y, dim.max.y); + x = p[5]; + y = p[6]; + } + } + var xmin = mmin[apply](0, X), + ymin = mmin[apply](0, Y), + xmax = mmax[apply](0, X), + ymax = mmax[apply](0, Y), + width = xmax - xmin, + height = ymax - ymin, + bb = { + x: xmin, + y: ymin, + x2: xmax, + y2: ymax, + width: width, + height: height, + cx: xmin + width / 2, + cy: ymin + height / 2 + }; + pth.bbox = clone(bb); + return bb; + }, + pathClone = function (pathArray) { + var res = clone(pathArray); + res.toString = R._path2string; + return res; + }, + pathToRelative = R._pathToRelative = function (pathArray) { + var pth = paths(pathArray); + if (pth.rel) { + return pathClone(pth.rel); + } + if (!R.is(pathArray, array) || !R.is(pathArray && pathArray[0], array)) { // rough assumption + pathArray = R.parsePathString(pathArray); + } + var res = [], + x = 0, + y = 0, + mx = 0, + my = 0, + start = 0; + if (pathArray[0][0] == "M") { + x = pathArray[0][1]; + y = pathArray[0][2]; + mx = x; + my = y; + start++; + res.push(["M", x, y]); + } + for (var i = start, ii = pathArray.length; i < ii; i++) { + var r = res[i] = [], + pa = pathArray[i]; + if (pa[0] != lowerCase.call(pa[0])) { + r[0] = lowerCase.call(pa[0]); + switch (r[0]) { + case "a": + r[1] = pa[1]; + r[2] = pa[2]; + r[3] = pa[3]; + r[4] = pa[4]; + r[5] = pa[5]; + r[6] = +(pa[6] - x).toFixed(3); + r[7] = +(pa[7] - y).toFixed(3); + break; + case "v": + r[1] = +(pa[1] - y).toFixed(3); + break; + case "m": + mx = pa[1]; + my = pa[2]; + default: + for (var j = 1, jj = pa.length; j < jj; j++) { + r[j] = +(pa[j] - ((j % 2) ? x : y)).toFixed(3); + } + } + } else { + r = res[i] = []; + if (pa[0] == "m") { + mx = pa[1] + x; + my = pa[2] + y; + } + for (var k = 0, kk = pa.length; k < kk; k++) { + res[i][k] = pa[k]; + } + } + var len = res[i].length; + switch (res[i][0]) { + case "z": + x = mx; + y = my; + break; + case "h": + x += +res[i][len - 1]; + break; + case "v": + y += +res[i][len - 1]; + break; + default: + x += +res[i][len - 2]; + y += +res[i][len - 1]; + } + } + res.toString = R._path2string; + pth.rel = pathClone(res); + return res; + }, + pathToAbsolute = R._pathToAbsolute = function (pathArray) { + var pth = paths(pathArray); + if (pth.abs) { + return pathClone(pth.abs); + } + if (!R.is(pathArray, array) || !R.is(pathArray && pathArray[0], array)) { // rough assumption + pathArray = R.parsePathString(pathArray); + } + if (!pathArray || !pathArray.length) { + return [["M", 0, 0]]; + } + var res = [], + x = 0, + y = 0, + mx = 0, + my = 0, + start = 0; + if (pathArray[0][0] == "M") { + x = +pathArray[0][1]; + y = +pathArray[0][2]; + mx = x; + my = y; + start++; + res[0] = ["M", x, y]; + } + var crz = pathArray.length == 3 && pathArray[0][0] == "M" && pathArray[1][0].toUpperCase() == "R" && pathArray[2][0].toUpperCase() == "Z"; + for (var r, pa, i = start, ii = pathArray.length; i < ii; i++) { + res.push(r = []); + pa = pathArray[i]; + if (pa[0] != upperCase.call(pa[0])) { + r[0] = upperCase.call(pa[0]); + switch (r[0]) { + case "A": + r[1] = pa[1]; + r[2] = pa[2]; + r[3] = pa[3]; + r[4] = pa[4]; + r[5] = pa[5]; + r[6] = +(pa[6] + x); + r[7] = +(pa[7] + y); + break; + case "V": + r[1] = +pa[1] + y; + break; + case "H": + r[1] = +pa[1] + x; + break; + case "R": + var dots = [x, y][concat](pa.slice(1)); + for (var j = 2, jj = dots.length; j < jj; j++) { + dots[j] = +dots[j] + x; + dots[++j] = +dots[j] + y; + } + res.pop(); + res = res[concat](catmullRom2bezier(dots, crz)); + break; + case "M": + mx = +pa[1] + x; + my = +pa[2] + y; + default: + for (j = 1, jj = pa.length; j < jj; j++) { + r[j] = +pa[j] + ((j % 2) ? x : y); + } + } + } else if (pa[0] == "R") { + dots = [x, y][concat](pa.slice(1)); + res.pop(); + res = res[concat](catmullRom2bezier(dots, crz)); + r = ["R"][concat](pa.slice(-2)); + } else { + for (var k = 0, kk = pa.length; k < kk; k++) { + r[k] = pa[k]; + } + } + switch (r[0]) { + case "Z": + x = mx; + y = my; + break; + case "H": + x = r[1]; + break; + case "V": + y = r[1]; + break; + case "M": + mx = r[r.length - 2]; + my = r[r.length - 1]; + default: + x = r[r.length - 2]; + y = r[r.length - 1]; + } + } + res.toString = R._path2string; + pth.abs = pathClone(res); + return res; + }, + l2c = function (x1, y1, x2, y2) { + return [x1, y1, x2, y2, x2, y2]; + }, + q2c = function (x1, y1, ax, ay, x2, y2) { + var _13 = 1 / 3, + _23 = 2 / 3; + return [ + _13 * x1 + _23 * ax, + _13 * y1 + _23 * ay, + _13 * x2 + _23 * ax, + _13 * y2 + _23 * ay, + x2, + y2 + ]; + }, + a2c = function (x1, y1, rx, ry, angle, large_arc_flag, sweep_flag, x2, y2, recursive) { + // for more information of where this math came from visit: + // http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes + var _120 = PI * 120 / 180, + rad = PI / 180 * (+angle || 0), + res = [], + xy, + rotate = cacher(function (x, y, rad) { + var X = x * math.cos(rad) - y * math.sin(rad), + Y = x * math.sin(rad) + y * math.cos(rad); + return {x: X, y: Y}; + }); + if (!recursive) { + xy = rotate(x1, y1, -rad); + x1 = xy.x; + y1 = xy.y; + xy = rotate(x2, y2, -rad); + x2 = xy.x; + y2 = xy.y; + var cos = math.cos(PI / 180 * angle), + sin = math.sin(PI / 180 * angle), + x = (x1 - x2) / 2, + y = (y1 - y2) / 2; + var h = (x * x) / (rx * rx) + (y * y) / (ry * ry); + if (h > 1) { + h = math.sqrt(h); + rx = h * rx; + ry = h * ry; + } + var rx2 = rx * rx, + ry2 = ry * ry, + k = (large_arc_flag == sweep_flag ? -1 : 1) * + math.sqrt(abs((rx2 * ry2 - rx2 * y * y - ry2 * x * x) / (rx2 * y * y + ry2 * x * x))), + cx = k * rx * y / ry + (x1 + x2) / 2, + cy = k * -ry * x / rx + (y1 + y2) / 2, + f1 = math.asin(((y1 - cy) / ry).toFixed(9)), + f2 = math.asin(((y2 - cy) / ry).toFixed(9)); + + f1 = x1 < cx ? PI - f1 : f1; + f2 = x2 < cx ? PI - f2 : f2; + f1 < 0 && (f1 = PI * 2 + f1); + f2 < 0 && (f2 = PI * 2 + f2); + if (sweep_flag && f1 > f2) { + f1 = f1 - PI * 2; + } + if (!sweep_flag && f2 > f1) { + f2 = f2 - PI * 2; + } + } else { + f1 = recursive[0]; + f2 = recursive[1]; + cx = recursive[2]; + cy = recursive[3]; + } + var df = f2 - f1; + if (abs(df) > _120) { + var f2old = f2, + x2old = x2, + y2old = y2; + f2 = f1 + _120 * (sweep_flag && f2 > f1 ? 1 : -1); + x2 = cx + rx * math.cos(f2); + y2 = cy + ry * math.sin(f2); + res = a2c(x2, y2, rx, ry, angle, 0, sweep_flag, x2old, y2old, [f2, f2old, cx, cy]); + } + df = f2 - f1; + var c1 = math.cos(f1), + s1 = math.sin(f1), + c2 = math.cos(f2), + s2 = math.sin(f2), + t = math.tan(df / 4), + hx = 4 / 3 * rx * t, + hy = 4 / 3 * ry * t, + m1 = [x1, y1], + m2 = [x1 + hx * s1, y1 - hy * c1], + m3 = [x2 + hx * s2, y2 - hy * c2], + m4 = [x2, y2]; + m2[0] = 2 * m1[0] - m2[0]; + m2[1] = 2 * m1[1] - m2[1]; + if (recursive) { + return [m2, m3, m4][concat](res); + } else { + res = [m2, m3, m4][concat](res).join()[split](","); + var newres = []; + for (var i = 0, ii = res.length; i < ii; i++) { + newres[i] = i % 2 ? rotate(res[i - 1], res[i], rad).y : rotate(res[i], res[i + 1], rad).x; + } + return newres; + } + }, + findDotAtSegment = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t) { + var t1 = 1 - t; + return { + x: pow(t1, 3) * p1x + pow(t1, 2) * 3 * t * c1x + t1 * 3 * t * t * c2x + pow(t, 3) * p2x, + y: pow(t1, 3) * p1y + pow(t1, 2) * 3 * t * c1y + t1 * 3 * t * t * c2y + pow(t, 3) * p2y + }; + }, + curveDim = cacher(function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y) { + var a = (c2x - 2 * c1x + p1x) - (p2x - 2 * c2x + c1x), + b = 2 * (c1x - p1x) - 2 * (c2x - c1x), + c = p1x - c1x, + t1 = (-b + math.sqrt(b * b - 4 * a * c)) / 2 / a, + t2 = (-b - math.sqrt(b * b - 4 * a * c)) / 2 / a, + y = [p1y, p2y], + x = [p1x, p2x], + dot; + abs(t1) > "1e12" && (t1 = .5); + abs(t2) > "1e12" && (t2 = .5); + if (t1 > 0 && t1 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t1); + x.push(dot.x); + y.push(dot.y); + } + if (t2 > 0 && t2 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t2); + x.push(dot.x); + y.push(dot.y); + } + a = (c2y - 2 * c1y + p1y) - (p2y - 2 * c2y + c1y); + b = 2 * (c1y - p1y) - 2 * (c2y - c1y); + c = p1y - c1y; + t1 = (-b + math.sqrt(b * b - 4 * a * c)) / 2 / a; + t2 = (-b - math.sqrt(b * b - 4 * a * c)) / 2 / a; + abs(t1) > "1e12" && (t1 = .5); + abs(t2) > "1e12" && (t2 = .5); + if (t1 > 0 && t1 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t1); + x.push(dot.x); + y.push(dot.y); + } + if (t2 > 0 && t2 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t2); + x.push(dot.x); + y.push(dot.y); + } + return { + min: {x: mmin[apply](0, x), y: mmin[apply](0, y)}, + max: {x: mmax[apply](0, x), y: mmax[apply](0, y)} + }; + }), + path2curve = R._path2curve = cacher(function (path, path2) { + var pth = !path2 && paths(path); + if (!path2 && pth.curve) { + return pathClone(pth.curve); + } + var p = pathToAbsolute(path), + p2 = path2 && pathToAbsolute(path2), + attrs = {x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null}, + attrs2 = {x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null}, + processPath = function (path, d, pcom) { + var nx, ny, tq = {T:1, Q:1}; + if (!path) { + return ["C", d.x, d.y, d.x, d.y, d.x, d.y]; + } + !(path[0] in tq) && (d.qx = d.qy = null); + switch (path[0]) { + case "M": + d.X = path[1]; + d.Y = path[2]; + break; + case "A": + path = ["C"][concat](a2c[apply](0, [d.x, d.y][concat](path.slice(1)))); + break; + case "S": + if (pcom == "C" || pcom == "S") { // In "S" case we have to take into account, if the previous command is C/S. + nx = d.x * 2 - d.bx; // And reflect the previous + ny = d.y * 2 - d.by; // command's control point relative to the current point. + } + else { // or some else or nothing + nx = d.x; + ny = d.y; + } + path = ["C", nx, ny][concat](path.slice(1)); + break; + case "T": + if (pcom == "Q" || pcom == "T") { // In "T" case we have to take into account, if the previous command is Q/T. + d.qx = d.x * 2 - d.qx; // And make a reflection similar + d.qy = d.y * 2 - d.qy; // to case "S". + } + else { // or something else or nothing + d.qx = d.x; + d.qy = d.y; + } + path = ["C"][concat](q2c(d.x, d.y, d.qx, d.qy, path[1], path[2])); + break; + case "Q": + d.qx = path[1]; + d.qy = path[2]; + path = ["C"][concat](q2c(d.x, d.y, path[1], path[2], path[3], path[4])); + break; + case "L": + path = ["C"][concat](l2c(d.x, d.y, path[1], path[2])); + break; + case "H": + path = ["C"][concat](l2c(d.x, d.y, path[1], d.y)); + break; + case "V": + path = ["C"][concat](l2c(d.x, d.y, d.x, path[1])); + break; + case "Z": + path = ["C"][concat](l2c(d.x, d.y, d.X, d.Y)); + break; + } + return path; + }, + fixArc = function (pp, i) { + if (pp[i].length > 7) { + pp[i].shift(); + var pi = pp[i]; + while (pi.length) { + pcoms1[i]="A"; // if created multiple C:s, their original seg is saved + p2 && (pcoms2[i]="A"); // the same as above + pp.splice(i++, 0, ["C"][concat](pi.splice(0, 6))); + } + pp.splice(i, 1); + ii = mmax(p.length, p2 && p2.length || 0); + } + }, + fixM = function (path1, path2, a1, a2, i) { + if (path1 && path2 && path1[i][0] == "M" && path2[i][0] != "M") { + path2.splice(i, 0, ["M", a2.x, a2.y]); + a1.bx = 0; + a1.by = 0; + a1.x = path1[i][1]; + a1.y = path1[i][2]; + ii = mmax(p.length, p2 && p2.length || 0); + } + }, + pcoms1 = [], // path commands of original path p + pcoms2 = [], // path commands of original path p2 + pfirst = "", // temporary holder for original path command + pcom = ""; // holder for previous path command of original path + for (var i = 0, ii = mmax(p.length, p2 && p2.length || 0); i < ii; i++) { + p[i] && (pfirst = p[i][0]); // save current path command + + if (pfirst != "C") // C is not saved yet, because it may be result of conversion + { + pcoms1[i] = pfirst; // Save current path command + i && ( pcom = pcoms1[i-1]); // Get previous path command pcom + } + p[i] = processPath(p[i], attrs, pcom); // Previous path command is inputted to processPath + + if (pcoms1[i] != "A" && pfirst == "C") pcoms1[i] = "C"; // A is the only command + // which may produce multiple C:s + // so we have to make sure that C is also C in original path + + fixArc(p, i); // fixArc adds also the right amount of A:s to pcoms1 + + if (p2) { // the same procedures is done to p2 + p2[i] && (pfirst = p2[i][0]); + if (pfirst != "C") + { + pcoms2[i] = pfirst; + i && (pcom = pcoms2[i-1]); + } + p2[i] = processPath(p2[i], attrs2, pcom); + + if (pcoms2[i]!="A" && pfirst=="C") pcoms2[i]="C"; + + fixArc(p2, i); + } + fixM(p, p2, attrs, attrs2, i); + fixM(p2, p, attrs2, attrs, i); + var seg = p[i], + seg2 = p2 && p2[i], + seglen = seg.length, + seg2len = p2 && seg2.length; + attrs.x = seg[seglen - 2]; + attrs.y = seg[seglen - 1]; + attrs.bx = toFloat(seg[seglen - 4]) || attrs.x; + attrs.by = toFloat(seg[seglen - 3]) || attrs.y; + attrs2.bx = p2 && (toFloat(seg2[seg2len - 4]) || attrs2.x); + attrs2.by = p2 && (toFloat(seg2[seg2len - 3]) || attrs2.y); + attrs2.x = p2 && seg2[seg2len - 2]; + attrs2.y = p2 && seg2[seg2len - 1]; + } + if (!p2) { + pth.curve = pathClone(p); + } + return p2 ? [p, p2] : p; + }, null, pathClone), + parseDots = R._parseDots = cacher(function (gradient) { + var dots = []; + for (var i = 0, ii = gradient.length; i < ii; i++) { + var dot = {}, + par = gradient[i].match(/^([^:]*):?([\d\.]*)/); + dot.color = R.getRGB(par[1]); + if (dot.color.error) { + return null; + } + dot.color = dot.color.hex; + par[2] && (dot.offset = par[2] + "%"); + dots.push(dot); + } + for (i = 1, ii = dots.length - 1; i < ii; i++) { + if (!dots[i].offset) { + var start = toFloat(dots[i - 1].offset || 0), + end = 0; + for (var j = i + 1; j < ii; j++) { + if (dots[j].offset) { + end = dots[j].offset; + break; + } + } + if (!end) { + end = 100; + j = ii; + } + end = toFloat(end); + var d = (end - start) / (j - i + 1); + for (; i < j; i++) { + start += d; + dots[i].offset = start + "%"; + } + } + } + return dots; + }), + tear = R._tear = function (el, paper) { + el == paper.top && (paper.top = el.prev); + el == paper.bottom && (paper.bottom = el.next); + el.next && (el.next.prev = el.prev); + el.prev && (el.prev.next = el.next); + }, + tofront = R._tofront = function (el, paper) { + if (paper.top === el) { + return; + } + tear(el, paper); + el.next = null; + el.prev = paper.top; + paper.top.next = el; + paper.top = el; + }, + toback = R._toback = function (el, paper) { + if (paper.bottom === el) { + return; + } + tear(el, paper); + el.next = paper.bottom; + el.prev = null; + paper.bottom.prev = el; + paper.bottom = el; + }, + insertafter = R._insertafter = function (el, el2, paper) { + tear(el, paper); + el2 == paper.top && (paper.top = el); + el2.next && (el2.next.prev = el); + el.next = el2.next; + el.prev = el2; + el2.next = el; + }, + insertbefore = R._insertbefore = function (el, el2, paper) { + tear(el, paper); + el2 == paper.bottom && (paper.bottom = el); + el2.prev && (el2.prev.next = el); + el.prev = el2.prev; + el2.prev = el; + el.next = el2; + }, + /*\ + * Raphael.toMatrix + [ method ] + ** + * Utility method + ** + * Returns matrix of transformations applied to a given path + > Parameters + - path (string) path string + - transform (string|array) transformation string + = (object) @Matrix + \*/ + toMatrix = R.toMatrix = function (path, transform) { + var bb = pathDimensions(path), + el = { + _: { + transform: E + }, + getBBox: function () { + return bb; + } + }; + extractTransform(el, transform); + return el.matrix; + }, + /*\ + * Raphael.transformPath + [ method ] + ** + * Utility method + ** + * Returns path transformed by a given transformation + > Parameters + - path (string) path string + - transform (string|array) transformation string + = (string) path + \*/ + transformPath = R.transformPath = function (path, transform) { + return mapPath(path, toMatrix(path, transform)); + }, + extractTransform = R._extractTransform = function (el, tstr) { + if (tstr == null) { + return el._.transform; + } + tstr = Str(tstr).replace(/\.{3}|\u2026/g, el._.transform || E); + var tdata = R.parseTransformString(tstr), + deg = 0, + dx = 0, + dy = 0, + sx = 1, + sy = 1, + _ = el._, + m = new Matrix; + _.transform = tdata || []; + if (tdata) { + for (var i = 0, ii = tdata.length; i < ii; i++) { + var t = tdata[i], + tlen = t.length, + command = Str(t[0]).toLowerCase(), + absolute = t[0] != command, + inver = absolute ? m.invert() : 0, + x1, + y1, + x2, + y2, + bb; + if (command == "t" && tlen == 3) { + if (absolute) { + x1 = inver.x(0, 0); + y1 = inver.y(0, 0); + x2 = inver.x(t[1], t[2]); + y2 = inver.y(t[1], t[2]); + m.translate(x2 - x1, y2 - y1); + } else { + m.translate(t[1], t[2]); + } + } else if (command == "r") { + if (tlen == 2) { + bb = bb || el.getBBox(1); + m.rotate(t[1], bb.x + bb.width / 2, bb.y + bb.height / 2); + deg += t[1]; + } else if (tlen == 4) { + if (absolute) { + x2 = inver.x(t[2], t[3]); + y2 = inver.y(t[2], t[3]); + m.rotate(t[1], x2, y2); + } else { + m.rotate(t[1], t[2], t[3]); + } + deg += t[1]; + } + } else if (command == "s") { + if (tlen == 2 || tlen == 3) { + bb = bb || el.getBBox(1); + m.scale(t[1], t[tlen - 1], bb.x + bb.width / 2, bb.y + bb.height / 2); + sx *= t[1]; + sy *= t[tlen - 1]; + } else if (tlen == 5) { + if (absolute) { + x2 = inver.x(t[3], t[4]); + y2 = inver.y(t[3], t[4]); + m.scale(t[1], t[2], x2, y2); + } else { + m.scale(t[1], t[2], t[3], t[4]); + } + sx *= t[1]; + sy *= t[2]; + } + } else if (command == "m" && tlen == 7) { + m.add(t[1], t[2], t[3], t[4], t[5], t[6]); + } + _.dirtyT = 1; + el.matrix = m; + } + } + + /*\ + * Element.matrix + [ property (object) ] + ** + * Keeps @Matrix object, which represents element transformation + \*/ + el.matrix = m; + + _.sx = sx; + _.sy = sy; + _.deg = deg; + _.dx = dx = m.e; + _.dy = dy = m.f; + + if (sx == 1 && sy == 1 && !deg && _.bbox) { + _.bbox.x += +dx; + _.bbox.y += +dy; + } else { + _.dirtyT = 1; + } + }, + getEmpty = function (item) { + var l = item[0]; + switch (l.toLowerCase()) { + case "t": return [l, 0, 0]; + case "m": return [l, 1, 0, 0, 1, 0, 0]; + case "r": if (item.length == 4) { + return [l, 0, item[2], item[3]]; + } else { + return [l, 0]; + } + case "s": if (item.length == 5) { + return [l, 1, 1, item[3], item[4]]; + } else if (item.length == 3) { + return [l, 1, 1]; + } else { + return [l, 1]; + } + } + }, + equaliseTransform = R._equaliseTransform = function (t1, t2) { + t2 = Str(t2).replace(/\.{3}|\u2026/g, t1); + t1 = R.parseTransformString(t1) || []; + t2 = R.parseTransformString(t2) || []; + var maxlength = mmax(t1.length, t2.length), + from = [], + to = [], + i = 0, j, jj, + tt1, tt2; + for (; i < maxlength; i++) { + tt1 = t1[i] || getEmpty(t2[i]); + tt2 = t2[i] || getEmpty(tt1); + if ((tt1[0] != tt2[0]) || + (tt1[0].toLowerCase() == "r" && (tt1[2] != tt2[2] || tt1[3] != tt2[3])) || + (tt1[0].toLowerCase() == "s" && (tt1[3] != tt2[3] || tt1[4] != tt2[4])) + ) { + return; + } + from[i] = []; + to[i] = []; + for (j = 0, jj = mmax(tt1.length, tt2.length); j < jj; j++) { + j in tt1 && (from[i][j] = tt1[j]); + j in tt2 && (to[i][j] = tt2[j]); + } + } + return { + from: from, + to: to + }; + }; + R._getContainer = function (x, y, w, h) { + var container; + container = h == null && !R.is(x, "object") ? g.doc.getElementById(x) : x; + if (container == null) { + return; + } + if (container.tagName) { + if (y == null) { + return { + container: container, + width: container.style.pixelWidth || container.offsetWidth, + height: container.style.pixelHeight || container.offsetHeight + }; + } else { + return { + container: container, + width: y, + height: w + }; + } + } + return { + container: 1, + x: x, + y: y, + width: w, + height: h + }; + }; + /*\ + * Raphael.pathToRelative + [ method ] + ** + * Utility method + ** + * Converts path to relative form + > Parameters + - pathString (string|array) path string or array of segments + = (array) array of segments. + \*/ + R.pathToRelative = pathToRelative; + R._engine = {}; + /*\ + * Raphael.path2curve + [ method ] + ** + * Utility method + ** + * Converts path to a new path where all segments are cubic bezier curves. + > Parameters + - pathString (string|array) path string or array of segments + = (array) array of segments. + \*/ + R.path2curve = path2curve; + /*\ + * Raphael.matrix + [ method ] + ** + * Utility method + ** + * Returns matrix based on given parameters. + > Parameters + - a (number) + - b (number) + - c (number) + - d (number) + - e (number) + - f (number) + = (object) @Matrix + \*/ + R.matrix = function (a, b, c, d, e, f) { + return new Matrix(a, b, c, d, e, f); + }; + function Matrix(a, b, c, d, e, f) { + if (a != null) { + this.a = +a; + this.b = +b; + this.c = +c; + this.d = +d; + this.e = +e; + this.f = +f; + } else { + this.a = 1; + this.b = 0; + this.c = 0; + this.d = 1; + this.e = 0; + this.f = 0; + } + } + (function (matrixproto) { + /*\ + * Matrix.add + [ method ] + ** + * Adds given matrix to existing one. + > Parameters + - a (number) + - b (number) + - c (number) + - d (number) + - e (number) + - f (number) + or + - matrix (object) @Matrix + \*/ + matrixproto.add = function (a, b, c, d, e, f) { + var out = [[], [], []], + m = [[this.a, this.c, this.e], [this.b, this.d, this.f], [0, 0, 1]], + matrix = [[a, c, e], [b, d, f], [0, 0, 1]], + x, y, z, res; + + if (a && a instanceof Matrix) { + matrix = [[a.a, a.c, a.e], [a.b, a.d, a.f], [0, 0, 1]]; + } + + for (x = 0; x < 3; x++) { + for (y = 0; y < 3; y++) { + res = 0; + for (z = 0; z < 3; z++) { + res += m[x][z] * matrix[z][y]; + } + out[x][y] = res; + } + } + this.a = out[0][0]; + this.b = out[1][0]; + this.c = out[0][1]; + this.d = out[1][1]; + this.e = out[0][2]; + this.f = out[1][2]; + }; + /*\ + * Matrix.invert + [ method ] + ** + * Returns inverted version of the matrix + = (object) @Matrix + \*/ + matrixproto.invert = function () { + var me = this, + x = me.a * me.d - me.b * me.c; + return new Matrix(me.d / x, -me.b / x, -me.c / x, me.a / x, (me.c * me.f - me.d * me.e) / x, (me.b * me.e - me.a * me.f) / x); + }; + /*\ + * Matrix.clone + [ method ] + ** + * Returns copy of the matrix + = (object) @Matrix + \*/ + matrixproto.clone = function () { + return new Matrix(this.a, this.b, this.c, this.d, this.e, this.f); + }; + /*\ + * Matrix.translate + [ method ] + ** + * Translate the matrix + > Parameters + - x (number) + - y (number) + \*/ + matrixproto.translate = function (x, y) { + this.add(1, 0, 0, 1, x, y); + }; + /*\ + * Matrix.scale + [ method ] + ** + * Scales the matrix + > Parameters + - x (number) + - y (number) #optional + - cx (number) #optional + - cy (number) #optional + \*/ + matrixproto.scale = function (x, y, cx, cy) { + y == null && (y = x); + (cx || cy) && this.add(1, 0, 0, 1, cx, cy); + this.add(x, 0, 0, y, 0, 0); + (cx || cy) && this.add(1, 0, 0, 1, -cx, -cy); + }; + /*\ + * Matrix.rotate + [ method ] + ** + * Rotates the matrix + > Parameters + - a (number) + - x (number) + - y (number) + \*/ + matrixproto.rotate = function (a, x, y) { + a = R.rad(a); + x = x || 0; + y = y || 0; + var cos = +math.cos(a).toFixed(9), + sin = +math.sin(a).toFixed(9); + this.add(cos, sin, -sin, cos, x, y); + this.add(1, 0, 0, 1, -x, -y); + }; + /*\ + * Matrix.x + [ method ] + ** + * Return x coordinate for given point after transformation described by the matrix. See also @Matrix.y + > Parameters + - x (number) + - y (number) + = (number) x + \*/ + matrixproto.x = function (x, y) { + return x * this.a + y * this.c + this.e; + }; + /*\ + * Matrix.y + [ method ] + ** + * Return y coordinate for given point after transformation described by the matrix. See also @Matrix.x + > Parameters + - x (number) + - y (number) + = (number) y + \*/ + matrixproto.y = function (x, y) { + return x * this.b + y * this.d + this.f; + }; + matrixproto.get = function (i) { + return +this[Str.fromCharCode(97 + i)].toFixed(4); + }; + matrixproto.toString = function () { + return R.svg ? + "matrix(" + [this.get(0), this.get(1), this.get(2), this.get(3), this.get(4), this.get(5)].join() + ")" : + [this.get(0), this.get(2), this.get(1), this.get(3), 0, 0].join(); + }; + matrixproto.toFilter = function () { + return "progid:DXImageTransform.Microsoft.Matrix(M11=" + this.get(0) + + ", M12=" + this.get(2) + ", M21=" + this.get(1) + ", M22=" + this.get(3) + + ", Dx=" + this.get(4) + ", Dy=" + this.get(5) + ", sizingmethod='auto expand')"; + }; + matrixproto.offset = function () { + return [this.e.toFixed(4), this.f.toFixed(4)]; + }; + function norm(a) { + return a[0] * a[0] + a[1] * a[1]; + } + function normalize(a) { + var mag = math.sqrt(norm(a)); + a[0] && (a[0] /= mag); + a[1] && (a[1] /= mag); + } + /*\ + * Matrix.split + [ method ] + ** + * Splits matrix into primitive transformations + = (object) in format: + o dx (number) translation by x + o dy (number) translation by y + o scalex (number) scale by x + o scaley (number) scale by y + o shear (number) shear + o rotate (number) rotation in deg + o isSimple (boolean) could it be represented via simple transformations + \*/ + matrixproto.split = function () { + var out = {}; + // translation + out.dx = this.e; + out.dy = this.f; + + // scale and shear + var row = [[this.a, this.c], [this.b, this.d]]; + out.scalex = math.sqrt(norm(row[0])); + normalize(row[0]); + + out.shear = row[0][0] * row[1][0] + row[0][1] * row[1][1]; + row[1] = [row[1][0] - row[0][0] * out.shear, row[1][1] - row[0][1] * out.shear]; + + out.scaley = math.sqrt(norm(row[1])); + normalize(row[1]); + out.shear /= out.scaley; + + // rotation + var sin = -row[0][1], + cos = row[1][1]; + if (cos < 0) { + out.rotate = R.deg(math.acos(cos)); + if (sin < 0) { + out.rotate = 360 - out.rotate; + } + } else { + out.rotate = R.deg(math.asin(sin)); + } + + out.isSimple = !+out.shear.toFixed(9) && (out.scalex.toFixed(9) == out.scaley.toFixed(9) || !out.rotate); + out.isSuperSimple = !+out.shear.toFixed(9) && out.scalex.toFixed(9) == out.scaley.toFixed(9) && !out.rotate; + out.noRotation = !+out.shear.toFixed(9) && !out.rotate; + return out; + }; + /*\ + * Matrix.toTransformString + [ method ] + ** + * Return transform string that represents given matrix + = (string) transform string + \*/ + matrixproto.toTransformString = function (shorter) { + var s = shorter || this[split](); + if (s.isSimple) { + s.scalex = +s.scalex.toFixed(4); + s.scaley = +s.scaley.toFixed(4); + s.rotate = +s.rotate.toFixed(4); + return (s.dx || s.dy ? "t" + [s.dx, s.dy] : E) + + (s.scalex != 1 || s.scaley != 1 ? "s" + [s.scalex, s.scaley, 0, 0] : E) + + (s.rotate ? "r" + [s.rotate, 0, 0] : E); + } else { + return "m" + [this.get(0), this.get(1), this.get(2), this.get(3), this.get(4), this.get(5)]; + } + }; + })(Matrix.prototype); + + // WebKit rendering bug workaround method + var version = navigator.userAgent.match(/Version\/(.*?)\s/) || navigator.userAgent.match(/Chrome\/(\d+)/); + if ((navigator.vendor == "Apple Computer, Inc.") && (version && version[1] < 4 || navigator.platform.slice(0, 2) == "iP") || + (navigator.vendor == "Google Inc." && version && version[1] < 8)) { + /*\ + * Paper.safari + [ method ] + ** + * There is an inconvenient rendering bug in Safari (WebKit): + * sometimes the rendering should be forced. + * This method should help with dealing with this bug. + \*/ + paperproto.safari = function () { + var rect = this.rect(-99, -99, this.width + 99, this.height + 99).attr({stroke: "none"}); + setTimeout(function () {rect.remove();}); + }; + } else { + paperproto.safari = fun; + } + + var preventDefault = function () { + this.returnValue = false; + }, + preventTouch = function () { + return this.originalEvent.preventDefault(); + }, + stopPropagation = function () { + this.cancelBubble = true; + }, + stopTouch = function () { + return this.originalEvent.stopPropagation(); + }, + getEventPosition = function (e) { + var scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft; + + return { + x: e.clientX + scrollX, + y: e.clientY + scrollY + }; + }, + addEvent = (function () { + if (g.doc.addEventListener) { + return function (obj, type, fn, element) { + var f = function (e) { + var pos = getEventPosition(e); + return fn.call(element, e, pos.x, pos.y); + }; + obj.addEventListener(type, f, false); + + if (supportsTouch && touchMap[type]) { + var _f = function (e) { + var pos = getEventPosition(e), + olde = e; + + for (var i = 0, ii = e.targetTouches && e.targetTouches.length; i < ii; i++) { + if (e.targetTouches[i].target == obj) { + e = e.targetTouches[i]; + e.originalEvent = olde; + e.preventDefault = preventTouch; + e.stopPropagation = stopTouch; + break; + } + } + + return fn.call(element, e, pos.x, pos.y); + }; + obj.addEventListener(touchMap[type], _f, false); + } + + return function () { + obj.removeEventListener(type, f, false); + + if (supportsTouch && touchMap[type]) + obj.removeEventListener(touchMap[type], _f, false); + + return true; + }; + }; + } else if (g.doc.attachEvent) { + return function (obj, type, fn, element) { + var f = function (e) { + e = e || g.win.event; + var scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft, + x = e.clientX + scrollX, + y = e.clientY + scrollY; + e.preventDefault = e.preventDefault || preventDefault; + e.stopPropagation = e.stopPropagation || stopPropagation; + return fn.call(element, e, x, y); + }; + obj.attachEvent("on" + type, f); + var detacher = function () { + obj.detachEvent("on" + type, f); + return true; + }; + return detacher; + }; + } + })(), + drag = [], + dragMove = function (e) { + var x = e.clientX, + y = e.clientY, + scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft, + dragi, + j = drag.length; + while (j--) { + dragi = drag[j]; + if (supportsTouch && e.touches) { + var i = e.touches.length, + touch; + while (i--) { + touch = e.touches[i]; + if (touch.identifier == dragi.el._drag.id) { + x = touch.clientX; + y = touch.clientY; + (e.originalEvent ? e.originalEvent : e).preventDefault(); + break; + } + } + } else { + e.preventDefault(); + } + var node = dragi.el.node, + o, + next = node.nextSibling, + parent = node.parentNode, + display = node.style.display; + g.win.opera && parent.removeChild(node); + node.style.display = "none"; + o = dragi.el.paper.getElementByPoint(x, y); + node.style.display = display; + g.win.opera && (next ? parent.insertBefore(node, next) : parent.appendChild(node)); + o && eve("raphael.drag.over." + dragi.el.id, dragi.el, o); + x += scrollX; + y += scrollY; + eve("raphael.drag.move." + dragi.el.id, dragi.move_scope || dragi.el, x - dragi.el._drag.x, y - dragi.el._drag.y, x, y, e); + } + }, + dragUp = function (e) { + R.unmousemove(dragMove).unmouseup(dragUp); + var i = drag.length, + dragi; + while (i--) { + dragi = drag[i]; + dragi.el._drag = {}; + eve("raphael.drag.end." + dragi.el.id, dragi.end_scope || dragi.start_scope || dragi.move_scope || dragi.el, e); + } + drag = []; + }, + /*\ + * Raphael.el + [ property (object) ] + ** + * You can add your own method to elements. This is usefull when you want to hack default functionality or + * want to wrap some common transformation or attributes in one method. In difference to canvas methods, + * you can redefine element method at any time. Expending element methods wouldn’t affect set. + > Usage + | Raphael.el.red = function () { + | this.attr({fill: "#f00"}); + | }; + | // then use it + | paper.circle(100, 100, 20).red(); + \*/ + elproto = R.el = {}; + /*\ + * Element.click + [ method ] + ** + * Adds event handler for click for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unclick + [ method ] + ** + * Removes event handler for click for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.dblclick + [ method ] + ** + * Adds event handler for double click for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.undblclick + [ method ] + ** + * Removes event handler for double click for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mousedown + [ method ] + ** + * Adds event handler for mousedown for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmousedown + [ method ] + ** + * Removes event handler for mousedown for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mousemove + [ method ] + ** + * Adds event handler for mousemove for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmousemove + [ method ] + ** + * Removes event handler for mousemove for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mouseout + [ method ] + ** + * Adds event handler for mouseout for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmouseout + [ method ] + ** + * Removes event handler for mouseout for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mouseover + [ method ] + ** + * Adds event handler for mouseover for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmouseover + [ method ] + ** + * Removes event handler for mouseover for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mouseup + [ method ] + ** + * Adds event handler for mouseup for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmouseup + [ method ] + ** + * Removes event handler for mouseup for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchstart + [ method ] + ** + * Adds event handler for touchstart for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchstart + [ method ] + ** + * Removes event handler for touchstart for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchmove + [ method ] + ** + * Adds event handler for touchmove for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchmove + [ method ] + ** + * Removes event handler for touchmove for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchend + [ method ] + ** + * Adds event handler for touchend for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchend + [ method ] + ** + * Removes event handler for touchend for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchcancel + [ method ] + ** + * Adds event handler for touchcancel for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchcancel + [ method ] + ** + * Removes event handler for touchcancel for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + for (var i = events.length; i--;) { + (function (eventName) { + R[eventName] = elproto[eventName] = function (fn, scope) { + if (R.is(fn, "function")) { + this.events = this.events || []; + this.events.push({name: eventName, f: fn, unbind: addEvent(this.shape || this.node || g.doc, eventName, fn, scope || this)}); + } + return this; + }; + R["un" + eventName] = elproto["un" + eventName] = function (fn) { + var events = this.events || [], + l = events.length; + while (l--){ + if (events[l].name == eventName && (R.is(fn, "undefined") || events[l].f == fn)) { + events[l].unbind(); + events.splice(l, 1); + !events.length && delete this.events; + } + } + return this; + }; + })(events[i]); + } + + /*\ + * Element.data + [ method ] + ** + * Adds or retrieves given value asociated with given key. + ** + * See also @Element.removeData + > Parameters + - key (string) key to store data + - value (any) #optional value to store + = (object) @Element + * or, if value is not specified: + = (any) value + * or, if key and value are not specified: + = (object) Key/value pairs for all the data associated with the element. + > Usage + | for (var i = 0, i < 5, i++) { + | paper.circle(10 + 15 * i, 10, 10) + | .attr({fill: "#000"}) + | .data("i", i) + | .click(function () { + | alert(this.data("i")); + | }); + | } + \*/ + elproto.data = function (key, value) { + var data = eldata[this.id] = eldata[this.id] || {}; + if (arguments.length == 0) { + return data; + } + if (arguments.length == 1) { + if (R.is(key, "object")) { + for (var i in key) if (key[has](i)) { + this.data(i, key[i]); + } + return this; + } + eve("raphael.data.get." + this.id, this, data[key], key); + return data[key]; + } + data[key] = value; + eve("raphael.data.set." + this.id, this, value, key); + return this; + }; + /*\ + * Element.removeData + [ method ] + ** + * Removes value associated with an element by given key. + * If key is not provided, removes all the data of the element. + > Parameters + - key (string) #optional key + = (object) @Element + \*/ + elproto.removeData = function (key) { + if (key == null) { + eldata[this.id] = {}; + } else { + eldata[this.id] && delete eldata[this.id][key]; + } + return this; + }; + /*\ + * Element.getData + [ method ] + ** + * Retrieves the element data + = (object) data + \*/ + elproto.getData = function () { + return clone(eldata[this.id] || {}); + }; + /*\ + * Element.hover + [ method ] + ** + * Adds event handlers for hover for the element. + > Parameters + - f_in (function) handler for hover in + - f_out (function) handler for hover out + - icontext (object) #optional context for hover in handler + - ocontext (object) #optional context for hover out handler + = (object) @Element + \*/ + elproto.hover = function (f_in, f_out, scope_in, scope_out) { + return this.mouseover(f_in, scope_in).mouseout(f_out, scope_out || scope_in); + }; + /*\ + * Element.unhover + [ method ] + ** + * Removes event handlers for hover for the element. + > Parameters + - f_in (function) handler for hover in + - f_out (function) handler for hover out + = (object) @Element + \*/ + elproto.unhover = function (f_in, f_out) { + return this.unmouseover(f_in).unmouseout(f_out); + }; + var draggable = []; + /*\ + * Element.drag + [ method ] + ** + * Adds event handlers for drag of the element. + > Parameters + - onmove (function) handler for moving + - onstart (function) handler for drag start + - onend (function) handler for drag end + - mcontext (object) #optional context for moving handler + - scontext (object) #optional context for drag start handler + - econtext (object) #optional context for drag end handler + * Additionaly following `drag` events will be triggered: `drag.start.` on start, + * `drag.end.` on end and `drag.move.` on every move. When element will be dragged over another element + * `drag.over.` will be fired as well. + * + * Start event and start handler will be called in specified context or in context of the element with following parameters: + o x (number) x position of the mouse + o y (number) y position of the mouse + o event (object) DOM event object + * Move event and move handler will be called in specified context or in context of the element with following parameters: + o dx (number) shift by x from the start point + o dy (number) shift by y from the start point + o x (number) x position of the mouse + o y (number) y position of the mouse + o event (object) DOM event object + * End event and end handler will be called in specified context or in context of the element with following parameters: + o event (object) DOM event object + = (object) @Element + \*/ + elproto.drag = function (onmove, onstart, onend, move_scope, start_scope, end_scope) { + function start(e) { + (e.originalEvent || e).preventDefault(); + var x = e.clientX, + y = e.clientY, + scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft; + this._drag.id = e.identifier; + if (supportsTouch && e.touches) { + var i = e.touches.length, touch; + while (i--) { + touch = e.touches[i]; + this._drag.id = touch.identifier; + if (touch.identifier == this._drag.id) { + x = touch.clientX; + y = touch.clientY; + break; + } + } + } + this._drag.x = x + scrollX; + this._drag.y = y + scrollY; + !drag.length && R.mousemove(dragMove).mouseup(dragUp); + drag.push({el: this, move_scope: move_scope, start_scope: start_scope, end_scope: end_scope}); + onstart && eve.on("raphael.drag.start." + this.id, onstart); + onmove && eve.on("raphael.drag.move." + this.id, onmove); + onend && eve.on("raphael.drag.end." + this.id, onend); + eve("raphael.drag.start." + this.id, start_scope || move_scope || this, e.clientX + scrollX, e.clientY + scrollY, e); + } + this._drag = {}; + draggable.push({el: this, start: start}); + this.mousedown(start); + return this; + }; + /*\ + * Element.onDragOver + [ method ] + ** + * Shortcut for assigning event handler for `drag.over.` event, where id is id of the element (see @Element.id). + > Parameters + - f (function) handler for event, first argument would be the element you are dragging over + \*/ + elproto.onDragOver = function (f) { + f ? eve.on("raphael.drag.over." + this.id, f) : eve.unbind("raphael.drag.over." + this.id); + }; + /*\ + * Element.undrag + [ method ] + ** + * Removes all drag event handlers from given element. + \*/ + elproto.undrag = function () { + var i = draggable.length; + while (i--) if (draggable[i].el == this) { + this.unmousedown(draggable[i].start); + draggable.splice(i, 1); + eve.unbind("raphael.drag.*." + this.id); + } + !draggable.length && R.unmousemove(dragMove).unmouseup(dragUp); + drag = []; + }; + /*\ + * Paper.circle + [ method ] + ** + * Draws a circle. + ** + > Parameters + ** + - x (number) x coordinate of the centre + - y (number) y coordinate of the centre + - r (number) radius + = (object) Raphaël element object with type “circle” + ** + > Usage + | var c = paper.circle(50, 50, 40); + \*/ + paperproto.circle = function (x, y, r) { + var out = R._engine.circle(this, x || 0, y || 0, r || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.rect + [ method ] + * + * Draws a rectangle. + ** + > Parameters + ** + - x (number) x coordinate of the top left corner + - y (number) y coordinate of the top left corner + - width (number) width + - height (number) height + - r (number) #optional radius for rounded corners, default is 0 + = (object) Raphaël element object with type “rect” + ** + > Usage + | // regular rectangle + | var c = paper.rect(10, 10, 50, 50); + | // rectangle with rounded corners + | var c = paper.rect(40, 40, 50, 50, 10); + \*/ + paperproto.rect = function (x, y, w, h, r) { + var out = R._engine.rect(this, x || 0, y || 0, w || 0, h || 0, r || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.ellipse + [ method ] + ** + * Draws an ellipse. + ** + > Parameters + ** + - x (number) x coordinate of the centre + - y (number) y coordinate of the centre + - rx (number) horizontal radius + - ry (number) vertical radius + = (object) Raphaël element object with type “ellipse” + ** + > Usage + | var c = paper.ellipse(50, 50, 40, 20); + \*/ + paperproto.ellipse = function (x, y, rx, ry) { + var out = R._engine.ellipse(this, x || 0, y || 0, rx || 0, ry || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.path + [ method ] + ** + * Creates a path element by given path data string. + > Parameters + - pathString (string) #optional path string in SVG format. + * Path string consists of one-letter commands, followed by comma seprarated arguments in numercal form. Example: + | "M10,20L30,40" + * Here we can see two commands: “M”, with arguments `(10, 20)` and “L” with arguments `(30, 40)`. Upper case letter mean command is absolute, lower case—relative. + * + #

    Here is short list of commands available, for more details see SVG path string format.

    + # + # + # + # + # + # + # + # + # + # + # + #
    CommandNameParameters
    Mmoveto(x y)+
    Zclosepath(none)
    Llineto(x y)+
    Hhorizontal linetox+
    Vvertical linetoy+
    Ccurveto(x1 y1 x2 y2 x y)+
    Ssmooth curveto(x2 y2 x y)+
    Qquadratic Bézier curveto(x1 y1 x y)+
    Tsmooth quadratic Bézier curveto(x y)+
    Aelliptical arc(rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
    RCatmull-Rom curveto*x1 y1 (x y)+
    + * * “Catmull-Rom curveto” is a not standard SVG command and added in 2.0 to make life easier. + * Note: there is a special case when path consist of just three commands: “M10,10R…z”. In this case path will smoothly connects to its beginning. + > Usage + | var c = paper.path("M10 10L90 90"); + | // draw a diagonal line: + | // move to 10,10, line to 90,90 + * For example of path strings, check out these icons: http://raphaeljs.com/icons/ + \*/ + paperproto.path = function (pathString) { + pathString && !R.is(pathString, string) && !R.is(pathString[0], array) && (pathString += E); + var out = R._engine.path(R.format[apply](R, arguments), this); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.image + [ method ] + ** + * Embeds an image into the surface. + ** + > Parameters + ** + - src (string) URI of the source image + - x (number) x coordinate position + - y (number) y coordinate position + - width (number) width of the image + - height (number) height of the image + = (object) Raphaël element object with type “image” + ** + > Usage + | var c = paper.image("apple.png", 10, 10, 80, 80); + \*/ + paperproto.image = function (src, x, y, w, h) { + var out = R._engine.image(this, src || "about:blank", x || 0, y || 0, w || 0, h || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.text + [ method ] + ** + * Draws a text string. If you need line breaks, put “\n” in the string. + ** + > Parameters + ** + - x (number) x coordinate position + - y (number) y coordinate position + - text (string) The text string to draw + = (object) Raphaël element object with type “text” + ** + > Usage + | var t = paper.text(50, 50, "Raphaël\nkicks\nbutt!"); + \*/ + paperproto.text = function (x, y, text) { + var out = R._engine.text(this, x || 0, y || 0, Str(text)); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.set + [ method ] + ** + * Creates array-like object to keep and operate several elements at once. + * Warning: it doesn’t create any elements for itself in the page, it just groups existing elements. + * Sets act as pseudo elements — all methods available to an element can be used on a set. + = (object) array-like object that represents set of elements + ** + > Usage + | var st = paper.set(); + | st.push( + | paper.circle(10, 10, 5), + | paper.circle(30, 10, 5) + | ); + | st.attr({fill: "red"}); // changes the fill of both circles + \*/ + paperproto.set = function (itemsArray) { + !R.is(itemsArray, "array") && (itemsArray = Array.prototype.splice.call(arguments, 0, arguments.length)); + var out = new Set(itemsArray); + this.__set__ && this.__set__.push(out); + out["paper"] = this; + out["type"] = "set"; + return out; + }; + /*\ + * Paper.setStart + [ method ] + ** + * Creates @Paper.set. All elements that will be created after calling this method and before calling + * @Paper.setFinish will be added to the set. + ** + > Usage + | paper.setStart(); + | paper.circle(10, 10, 5), + | paper.circle(30, 10, 5) + | var st = paper.setFinish(); + | st.attr({fill: "red"}); // changes the fill of both circles + \*/ + paperproto.setStart = function (set) { + this.__set__ = set || this.set(); + }; + /*\ + * Paper.setFinish + [ method ] + ** + * See @Paper.setStart. This method finishes catching and returns resulting set. + ** + = (object) set + \*/ + paperproto.setFinish = function (set) { + var out = this.__set__; + delete this.__set__; + return out; + }; + /*\ + * Paper.getSize + [ method ] + ** + * Obtains current paper actual size. + ** + = (object) + \*/ + paperproto.getSize = function () { + var container = this.canvas.parentNode; + return { + width: container.offsetWidth, + height: container.offsetHeight + }; + }; + /*\ + * Paper.setSize + [ method ] + ** + * If you need to change dimensions of the canvas call this method + ** + > Parameters + ** + - width (number) new width of the canvas + - height (number) new height of the canvas + \*/ + paperproto.setSize = function (width, height) { + return R._engine.setSize.call(this, width, height); + }; + /*\ + * Paper.setViewBox + [ method ] + ** + * Sets the view box of the paper. Practically it gives you ability to zoom and pan whole paper surface by + * specifying new boundaries. + ** + > Parameters + ** + - x (number) new x position, default is `0` + - y (number) new y position, default is `0` + - w (number) new width of the canvas + - h (number) new height of the canvas + - fit (boolean) `true` if you want graphics to fit into new boundary box + \*/ + paperproto.setViewBox = function (x, y, w, h, fit) { + return R._engine.setViewBox.call(this, x, y, w, h, fit); + }; + /*\ + * Paper.top + [ property ] + ** + * Points to the topmost element on the paper + \*/ + /*\ + * Paper.bottom + [ property ] + ** + * Points to the bottom element on the paper + \*/ + paperproto.top = paperproto.bottom = null; + /*\ + * Paper.raphael + [ property ] + ** + * Points to the @Raphael object/function + \*/ + paperproto.raphael = R; + var getOffset = function (elem) { + var box = elem.getBoundingClientRect(), + doc = elem.ownerDocument, + body = doc.body, + docElem = doc.documentElement, + clientTop = docElem.clientTop || body.clientTop || 0, clientLeft = docElem.clientLeft || body.clientLeft || 0, + top = box.top + (g.win.pageYOffset || docElem.scrollTop || body.scrollTop ) - clientTop, + left = box.left + (g.win.pageXOffset || docElem.scrollLeft || body.scrollLeft) - clientLeft; + return { + y: top, + x: left + }; + }; + /*\ + * Paper.getElementByPoint + [ method ] + ** + * Returns you topmost element under given point. + ** + = (object) Raphaël element object + > Parameters + ** + - x (number) x coordinate from the top left corner of the window + - y (number) y coordinate from the top left corner of the window + > Usage + | paper.getElementByPoint(mouseX, mouseY).attr({stroke: "#f00"}); + \*/ + paperproto.getElementByPoint = function (x, y) { + var paper = this, + svg = paper.canvas, + target = g.doc.elementFromPoint(x, y); + if (g.win.opera && target.tagName == "svg") { + var so = getOffset(svg), + sr = svg.createSVGRect(); + sr.x = x - so.x; + sr.y = y - so.y; + sr.width = sr.height = 1; + var hits = svg.getIntersectionList(sr, null); + if (hits.length) { + target = hits[hits.length - 1]; + } + } + if (!target) { + return null; + } + while (target.parentNode && target != svg.parentNode && !target.raphael) { + target = target.parentNode; + } + target == paper.canvas.parentNode && (target = svg); + target = target && target.raphael ? paper.getById(target.raphaelid) : null; + return target; + }; + + /*\ + * Paper.getElementsByBBox + [ method ] + ** + * Returns set of elements that have an intersecting bounding box + ** + > Parameters + ** + - bbox (object) bbox to check with + = (object) @Set + \*/ + paperproto.getElementsByBBox = function (bbox) { + var set = this.set(); + this.forEach(function (el) { + if (R.isBBoxIntersect(el.getBBox(), bbox)) { + set.push(el); + } + }); + return set; + }; + + /*\ + * Paper.getById + [ method ] + ** + * Returns you element by its internal ID. + ** + > Parameters + ** + - id (number) id + = (object) Raphaël element object + \*/ + paperproto.getById = function (id) { + var bot = this.bottom; + while (bot) { + if (bot.id == id) { + return bot; + } + bot = bot.next; + } + return null; + }; + /*\ + * Paper.forEach + [ method ] + ** + * Executes given function for each element on the paper + * + * If callback function returns `false` it will stop loop running. + ** + > Parameters + ** + - callback (function) function to run + - thisArg (object) context object for the callback + = (object) Paper object + > Usage + | paper.forEach(function (el) { + | el.attr({ stroke: "blue" }); + | }); + \*/ + paperproto.forEach = function (callback, thisArg) { + var bot = this.bottom; + while (bot) { + if (callback.call(thisArg, bot) === false) { + return this; + } + bot = bot.next; + } + return this; + }; + /*\ + * Paper.getElementsByPoint + [ method ] + ** + * Returns set of elements that have common point inside + ** + > Parameters + ** + - x (number) x coordinate of the point + - y (number) y coordinate of the point + = (object) @Set + \*/ + paperproto.getElementsByPoint = function (x, y) { + var set = this.set(); + this.forEach(function (el) { + if (el.isPointInside(x, y)) { + set.push(el); + } + }); + return set; + }; + function x_y() { + return this.x + S + this.y; + } + function x_y_w_h() { + return this.x + S + this.y + S + this.width + " \xd7 " + this.height; + } + /*\ + * Element.isPointInside + [ method ] + ** + * Determine if given point is inside this element’s shape + ** + > Parameters + ** + - x (number) x coordinate of the point + - y (number) y coordinate of the point + = (boolean) `true` if point inside the shape + \*/ + elproto.isPointInside = function (x, y) { + var rp = this.realPath = getPath[this.type](this); + if (this.attr('transform') && this.attr('transform').length) { + rp = R.transformPath(rp, this.attr('transform')); + } + return R.isPointInsidePath(rp, x, y); + }; + /*\ + * Element.getBBox + [ method ] + ** + * Return bounding box for a given element + ** + > Parameters + ** + - isWithoutTransform (boolean) flag, `true` if you want to have bounding box before transformations. Default is `false`. + = (object) Bounding box object: + o { + o x: (number) top left corner x + o y: (number) top left corner y + o x2: (number) bottom right corner x + o y2: (number) bottom right corner y + o width: (number) width + o height: (number) height + o } + \*/ + elproto.getBBox = function (isWithoutTransform) { + if (this.removed) { + return {}; + } + var _ = this._; + if (isWithoutTransform) { + if (_.dirty || !_.bboxwt) { + this.realPath = getPath[this.type](this); + _.bboxwt = pathDimensions(this.realPath); + _.bboxwt.toString = x_y_w_h; + _.dirty = 0; + } + return _.bboxwt; + } + if (_.dirty || _.dirtyT || !_.bbox) { + if (_.dirty || !this.realPath) { + _.bboxwt = 0; + this.realPath = getPath[this.type](this); + } + _.bbox = pathDimensions(mapPath(this.realPath, this.matrix)); + _.bbox.toString = x_y_w_h; + _.dirty = _.dirtyT = 0; + } + return _.bbox; + }; + /*\ + * Element.clone + [ method ] + ** + = (object) clone of a given element + ** + \*/ + elproto.clone = function () { + if (this.removed) { + return null; + } + var out = this.paper[this.type]().attr(this.attr()); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Element.glow + [ method ] + ** + * Return set of elements that create glow-like effect around given element. See @Paper.set. + * + * Note: Glow is not connected to the element. If you change element attributes it won’t adjust itself. + ** + > Parameters + ** + - glow (object) #optional parameters object with all properties optional: + o { + o width (number) size of the glow, default is `10` + o fill (boolean) will it be filled, default is `false` + o opacity (number) opacity, default is `0.5` + o offsetx (number) horizontal offset, default is `0` + o offsety (number) vertical offset, default is `0` + o color (string) glow colour, default is `black` + o } + = (object) @Paper.set of elements that represents glow + \*/ + elproto.glow = function (glow) { + if (this.type == "text") { + return null; + } + glow = glow || {}; + var s = { + width: (glow.width || 10) + (+this.attr("stroke-width") || 1), + fill: glow.fill || false, + opacity: glow.opacity || .5, + offsetx: glow.offsetx || 0, + offsety: glow.offsety || 0, + color: glow.color || "#000" + }, + c = s.width / 2, + r = this.paper, + out = r.set(), + path = this.realPath || getPath[this.type](this); + path = this.matrix ? mapPath(path, this.matrix) : path; + for (var i = 1; i < c + 1; i++) { + out.push(r.path(path).attr({ + stroke: s.color, + fill: s.fill ? s.color : "none", + "stroke-linejoin": "round", + "stroke-linecap": "round", + "stroke-width": +(s.width / c * i).toFixed(3), + opacity: +(s.opacity / c).toFixed(3) + })); + } + return out.insertBefore(this).translate(s.offsetx, s.offsety); + }; + var curveslengths = {}, + getPointAtSegmentLength = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, length) { + if (length == null) { + return bezlen(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y); + } else { + return R.findDotsAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, getTatLen(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, length)); + } + }, + getLengthFactory = function (istotal, subpath) { + return function (path, length, onlystart) { + path = path2curve(path); + var x, y, p, l, sp = "", subpaths = {}, point, + len = 0; + for (var i = 0, ii = path.length; i < ii; i++) { + p = path[i]; + if (p[0] == "M") { + x = +p[1]; + y = +p[2]; + } else { + l = getPointAtSegmentLength(x, y, p[1], p[2], p[3], p[4], p[5], p[6]); + if (len + l > length) { + if (subpath && !subpaths.start) { + point = getPointAtSegmentLength(x, y, p[1], p[2], p[3], p[4], p[5], p[6], length - len); + sp += ["C" + point.start.x, point.start.y, point.m.x, point.m.y, point.x, point.y]; + if (onlystart) {return sp;} + subpaths.start = sp; + sp = ["M" + point.x, point.y + "C" + point.n.x, point.n.y, point.end.x, point.end.y, p[5], p[6]].join(); + len += l; + x = +p[5]; + y = +p[6]; + continue; + } + if (!istotal && !subpath) { + point = getPointAtSegmentLength(x, y, p[1], p[2], p[3], p[4], p[5], p[6], length - len); + return {x: point.x, y: point.y, alpha: point.alpha}; + } + } + len += l; + x = +p[5]; + y = +p[6]; + } + sp += p.shift() + p; + } + subpaths.end = sp; + point = istotal ? len : subpath ? subpaths : R.findDotsAtSegment(x, y, p[0], p[1], p[2], p[3], p[4], p[5], 1); + point.alpha && (point = {x: point.x, y: point.y, alpha: point.alpha}); + return point; + }; + }; + var getTotalLength = getLengthFactory(1), + getPointAtLength = getLengthFactory(), + getSubpathsAtLength = getLengthFactory(0, 1); + /*\ + * Raphael.getTotalLength + [ method ] + ** + * Returns length of the given path in pixels. + ** + > Parameters + ** + - path (string) SVG path string. + ** + = (number) length. + \*/ + R.getTotalLength = getTotalLength; + /*\ + * Raphael.getPointAtLength + [ method ] + ** + * Return coordinates of the point located at the given length on the given path. + ** + > Parameters + ** + - path (string) SVG path string + - length (number) + ** + = (object) representation of the point: + o { + o x: (number) x coordinate + o y: (number) y coordinate + o alpha: (number) angle of derivative + o } + \*/ + R.getPointAtLength = getPointAtLength; + /*\ + * Raphael.getSubpath + [ method ] + ** + * Return subpath of a given path from given length to given length. + ** + > Parameters + ** + - path (string) SVG path string + - from (number) position of the start of the segment + - to (number) position of the end of the segment + ** + = (string) pathstring for the segment + \*/ + R.getSubpath = function (path, from, to) { + if (this.getTotalLength(path) - to < 1e-6) { + return getSubpathsAtLength(path, from).end; + } + var a = getSubpathsAtLength(path, to, 1); + return from ? getSubpathsAtLength(a, from).end : a; + }; + /*\ + * Element.getTotalLength + [ method ] + ** + * Returns length of the path in pixels. Only works for element of “path” type. + = (number) length. + \*/ + elproto.getTotalLength = function () { + var path = this.getPath(); + if (!path) { + return; + } + + if (this.node.getTotalLength) { + return this.node.getTotalLength(); + } + + return getTotalLength(path); + }; + /*\ + * Element.getPointAtLength + [ method ] + ** + * Return coordinates of the point located at the given length on the given path. Only works for element of “path” type. + ** + > Parameters + ** + - length (number) + ** + = (object) representation of the point: + o { + o x: (number) x coordinate + o y: (number) y coordinate + o alpha: (number) angle of derivative + o } + \*/ + elproto.getPointAtLength = function (length) { + var path = this.getPath(); + if (!path) { + return; + } + + return getPointAtLength(path, length); + }; + /*\ + * Element.getPath + [ method ] + ** + * Returns path of the element. Only works for elements of “path” type and simple elements like circle. + = (object) path + ** + \*/ + elproto.getPath = function () { + var path, + getPath = R._getPath[this.type]; + + if (this.type == "text" || this.type == "set") { + return; + } + + if (getPath) { + path = getPath(this); + } + + return path; + }; + /*\ + * Element.getSubpath + [ method ] + ** + * Return subpath of a given element from given length to given length. Only works for element of “path” type. + ** + > Parameters + ** + - from (number) position of the start of the segment + - to (number) position of the end of the segment + ** + = (string) pathstring for the segment + \*/ + elproto.getSubpath = function (from, to) { + var path = this.getPath(); + if (!path) { + return; + } + + return R.getSubpath(path, from, to); + }; + /*\ + * Raphael.easing_formulas + [ property ] + ** + * Object that contains easing formulas for animation. You could extend it with your own. By default it has following list of easing: + #
      + #
    • “linear”
    • + #
    • “<” or “easeIn” or “ease-in”
    • + #
    • “>” or “easeOut” or “ease-out”
    • + #
    • “<>” or “easeInOut” or “ease-in-out”
    • + #
    • “backIn” or “back-in”
    • + #
    • “backOut” or “back-out”
    • + #
    • “elastic”
    • + #
    • “bounce”
    • + #
    + #

    See also Easing demo.

    + \*/ + var ef = R.easing_formulas = { + linear: function (n) { + return n; + }, + "<": function (n) { + return pow(n, 1.7); + }, + ">": function (n) { + return pow(n, .48); + }, + "<>": function (n) { + var q = .48 - n / 1.04, + Q = math.sqrt(.1734 + q * q), + x = Q - q, + X = pow(abs(x), 1 / 3) * (x < 0 ? -1 : 1), + y = -Q - q, + Y = pow(abs(y), 1 / 3) * (y < 0 ? -1 : 1), + t = X + Y + .5; + return (1 - t) * 3 * t * t + t * t * t; + }, + backIn: function (n) { + var s = 1.70158; + return n * n * ((s + 1) * n - s); + }, + backOut: function (n) { + n = n - 1; + var s = 1.70158; + return n * n * ((s + 1) * n + s) + 1; + }, + elastic: function (n) { + if (n == !!n) { + return n; + } + return pow(2, -10 * n) * math.sin((n - .075) * (2 * PI) / .3) + 1; + }, + bounce: function (n) { + var s = 7.5625, + p = 2.75, + l; + if (n < (1 / p)) { + l = s * n * n; + } else { + if (n < (2 / p)) { + n -= (1.5 / p); + l = s * n * n + .75; + } else { + if (n < (2.5 / p)) { + n -= (2.25 / p); + l = s * n * n + .9375; + } else { + n -= (2.625 / p); + l = s * n * n + .984375; + } + } + } + return l; + } + }; + ef.easeIn = ef["ease-in"] = ef["<"]; + ef.easeOut = ef["ease-out"] = ef[">"]; + ef.easeInOut = ef["ease-in-out"] = ef["<>"]; + ef["back-in"] = ef.backIn; + ef["back-out"] = ef.backOut; + + var animationElements = [], + requestAnimFrame = window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function (callback) { + setTimeout(callback, 16); + }, + animation = function () { + var Now = +new Date, + l = 0; + for (; l < animationElements.length; l++) { + var e = animationElements[l]; + if (e.el.removed || e.paused) { + continue; + } + var time = Now - e.start, + ms = e.ms, + easing = e.easing, + from = e.from, + diff = e.diff, + to = e.to, + t = e.t, + that = e.el, + set = {}, + now, + init = {}, + key; + if (e.initstatus) { + time = (e.initstatus * e.anim.top - e.prev) / (e.percent - e.prev) * ms; + e.status = e.initstatus; + delete e.initstatus; + e.stop && animationElements.splice(l--, 1); + } else { + e.status = (e.prev + (e.percent - e.prev) * (time / ms)) / e.anim.top; + } + if (time < 0) { + continue; + } + if (time < ms) { + var pos = easing(time / ms); + for (var attr in from) if (from[has](attr)) { + switch (availableAnimAttrs[attr]) { + case nu: + now = +from[attr] + pos * ms * diff[attr]; + break; + case "colour": + now = "rgb(" + [ + upto255(round(from[attr].r + pos * ms * diff[attr].r)), + upto255(round(from[attr].g + pos * ms * diff[attr].g)), + upto255(round(from[attr].b + pos * ms * diff[attr].b)) + ].join(",") + ")"; + break; + case "path": + now = []; + for (var i = 0, ii = from[attr].length; i < ii; i++) { + now[i] = [from[attr][i][0]]; + for (var j = 1, jj = from[attr][i].length; j < jj; j++) { + now[i][j] = +from[attr][i][j] + pos * ms * diff[attr][i][j]; + } + now[i] = now[i].join(S); + } + now = now.join(S); + break; + case "transform": + if (diff[attr].real) { + now = []; + for (i = 0, ii = from[attr].length; i < ii; i++) { + now[i] = [from[attr][i][0]]; + for (j = 1, jj = from[attr][i].length; j < jj; j++) { + now[i][j] = from[attr][i][j] + pos * ms * diff[attr][i][j]; + } + } + } else { + var get = function (i) { + return +from[attr][i] + pos * ms * diff[attr][i]; + }; + // now = [["r", get(2), 0, 0], ["t", get(3), get(4)], ["s", get(0), get(1), 0, 0]]; + now = [["m", get(0), get(1), get(2), get(3), get(4), get(5)]]; + } + break; + case "csv": + if (attr == "clip-rect") { + now = []; + i = 4; + while (i--) { + now[i] = +from[attr][i] + pos * ms * diff[attr][i]; + } + } + break; + default: + var from2 = [][concat](from[attr]); + now = []; + i = that.paper.customAttributes[attr].length; + while (i--) { + now[i] = +from2[i] + pos * ms * diff[attr][i]; + } + break; + } + set[attr] = now; + } + that.attr(set); + (function (id, that, anim) { + setTimeout(function () { + eve("raphael.anim.frame." + id, that, anim); + }); + })(that.id, that, e.anim); + } else { + (function(f, el, a) { + setTimeout(function() { + eve("raphael.anim.frame." + el.id, el, a); + eve("raphael.anim.finish." + el.id, el, a); + R.is(f, "function") && f.call(el); + }); + })(e.callback, that, e.anim); + that.attr(to); + animationElements.splice(l--, 1); + if (e.repeat > 1 && !e.next) { + for (key in to) if (to[has](key)) { + init[key] = e.totalOrigin[key]; + } + e.el.attr(init); + runAnimation(e.anim, e.el, e.anim.percents[0], null, e.totalOrigin, e.repeat - 1); + } + if (e.next && !e.stop) { + runAnimation(e.anim, e.el, e.next, null, e.totalOrigin, e.repeat); + } + } + } + R.svg && that && that.paper && that.paper.safari(); + animationElements.length && requestAnimFrame(animation); + }, + upto255 = function (color) { + return color > 255 ? 255 : color < 0 ? 0 : color; + }; + /*\ + * Element.animateWith + [ method ] + ** + * Acts similar to @Element.animate, but ensure that given animation runs in sync with another given element. + ** + > Parameters + ** + - el (object) element to sync with + - anim (object) animation to sync with + - params (object) #optional final attributes for the element, see also @Element.attr + - ms (number) #optional number of milliseconds for animation to run + - easing (string) #optional easing type. Accept on of @Raphael.easing_formulas or CSS format: `cubic‐bezier(XX, XX, XX, XX)` + - callback (function) #optional callback function. Will be called at the end of animation. + * or + - element (object) element to sync with + - anim (object) animation to sync with + - animation (object) #optional animation object, see @Raphael.animation + ** + = (object) original element + \*/ + elproto.animateWith = function (el, anim, params, ms, easing, callback) { + var element = this; + if (element.removed) { + callback && callback.call(element); + return element; + } + var a = params instanceof Animation ? params : R.animation(params, ms, easing, callback), + x, y; + runAnimation(a, element, a.percents[0], null, element.attr()); + for (var i = 0, ii = animationElements.length; i < ii; i++) { + if (animationElements[i].anim == anim && animationElements[i].el == el) { + animationElements[ii - 1].start = animationElements[i].start; + break; + } + } + return element; + // + // + // var a = params ? R.animation(params, ms, easing, callback) : anim, + // status = element.status(anim); + // return this.animate(a).status(a, status * anim.ms / a.ms); + }; + function CubicBezierAtTime(t, p1x, p1y, p2x, p2y, duration) { + var cx = 3 * p1x, + bx = 3 * (p2x - p1x) - cx, + ax = 1 - cx - bx, + cy = 3 * p1y, + by = 3 * (p2y - p1y) - cy, + ay = 1 - cy - by; + function sampleCurveX(t) { + return ((ax * t + bx) * t + cx) * t; + } + function solve(x, epsilon) { + var t = solveCurveX(x, epsilon); + return ((ay * t + by) * t + cy) * t; + } + function solveCurveX(x, epsilon) { + var t0, t1, t2, x2, d2, i; + for(t2 = x, i = 0; i < 8; i++) { + x2 = sampleCurveX(t2) - x; + if (abs(x2) < epsilon) { + return t2; + } + d2 = (3 * ax * t2 + 2 * bx) * t2 + cx; + if (abs(d2) < 1e-6) { + break; + } + t2 = t2 - x2 / d2; + } + t0 = 0; + t1 = 1; + t2 = x; + if (t2 < t0) { + return t0; + } + if (t2 > t1) { + return t1; + } + while (t0 < t1) { + x2 = sampleCurveX(t2); + if (abs(x2 - x) < epsilon) { + return t2; + } + if (x > x2) { + t0 = t2; + } else { + t1 = t2; + } + t2 = (t1 - t0) / 2 + t0; + } + return t2; + } + return solve(t, 1 / (200 * duration)); + } + elproto.onAnimation = function (f) { + f ? eve.on("raphael.anim.frame." + this.id, f) : eve.unbind("raphael.anim.frame." + this.id); + return this; + }; + function Animation(anim, ms) { + var percents = [], + newAnim = {}; + this.ms = ms; + this.times = 1; + if (anim) { + for (var attr in anim) if (anim[has](attr)) { + newAnim[toFloat(attr)] = anim[attr]; + percents.push(toFloat(attr)); + } + percents.sort(sortByNumber); + } + this.anim = newAnim; + this.top = percents[percents.length - 1]; + this.percents = percents; + } + /*\ + * Animation.delay + [ method ] + ** + * Creates a copy of existing animation object with given delay. + ** + > Parameters + ** + - delay (number) number of ms to pass between animation start and actual animation + ** + = (object) new altered Animation object + | var anim = Raphael.animation({cx: 10, cy: 20}, 2e3); + | circle1.animate(anim); // run the given animation immediately + | circle2.animate(anim.delay(500)); // run the given animation after 500 ms + \*/ + Animation.prototype.delay = function (delay) { + var a = new Animation(this.anim, this.ms); + a.times = this.times; + a.del = +delay || 0; + return a; + }; + /*\ + * Animation.repeat + [ method ] + ** + * Creates a copy of existing animation object with given repetition. + ** + > Parameters + ** + - repeat (number) number iterations of animation. For infinite animation pass `Infinity` + ** + = (object) new altered Animation object + \*/ + Animation.prototype.repeat = function (times) { + var a = new Animation(this.anim, this.ms); + a.del = this.del; + a.times = math.floor(mmax(times, 0)) || 1; + return a; + }; + function runAnimation(anim, element, percent, status, totalOrigin, times) { + percent = toFloat(percent); + var params, + isInAnim, + isInAnimSet, + percents = [], + next, + prev, + timestamp, + ms = anim.ms, + from = {}, + to = {}, + diff = {}; + if (status) { + for (i = 0, ii = animationElements.length; i < ii; i++) { + var e = animationElements[i]; + if (e.el.id == element.id && e.anim == anim) { + if (e.percent != percent) { + animationElements.splice(i, 1); + isInAnimSet = 1; + } else { + isInAnim = e; + } + element.attr(e.totalOrigin); + break; + } + } + } else { + status = +to; // NaN + } + for (var i = 0, ii = anim.percents.length; i < ii; i++) { + if (anim.percents[i] == percent || anim.percents[i] > status * anim.top) { + percent = anim.percents[i]; + prev = anim.percents[i - 1] || 0; + ms = ms / anim.top * (percent - prev); + next = anim.percents[i + 1]; + params = anim.anim[percent]; + break; + } else if (status) { + element.attr(anim.anim[anim.percents[i]]); + } + } + if (!params) { + return; + } + if (!isInAnim) { + for (var attr in params) if (params[has](attr)) { + if (availableAnimAttrs[has](attr) || element.paper.customAttributes[has](attr)) { + from[attr] = element.attr(attr); + (from[attr] == null) && (from[attr] = availableAttrs[attr]); + to[attr] = params[attr]; + switch (availableAnimAttrs[attr]) { + case nu: + diff[attr] = (to[attr] - from[attr]) / ms; + break; + case "colour": + from[attr] = R.getRGB(from[attr]); + var toColour = R.getRGB(to[attr]); + diff[attr] = { + r: (toColour.r - from[attr].r) / ms, + g: (toColour.g - from[attr].g) / ms, + b: (toColour.b - from[attr].b) / ms + }; + break; + case "path": + var pathes = path2curve(from[attr], to[attr]), + toPath = pathes[1]; + from[attr] = pathes[0]; + diff[attr] = []; + for (i = 0, ii = from[attr].length; i < ii; i++) { + diff[attr][i] = [0]; + for (var j = 1, jj = from[attr][i].length; j < jj; j++) { + diff[attr][i][j] = (toPath[i][j] - from[attr][i][j]) / ms; + } + } + break; + case "transform": + var _ = element._, + eq = equaliseTransform(_[attr], to[attr]); + if (eq) { + from[attr] = eq.from; + to[attr] = eq.to; + diff[attr] = []; + diff[attr].real = true; + for (i = 0, ii = from[attr].length; i < ii; i++) { + diff[attr][i] = [from[attr][i][0]]; + for (j = 1, jj = from[attr][i].length; j < jj; j++) { + diff[attr][i][j] = (to[attr][i][j] - from[attr][i][j]) / ms; + } + } + } else { + var m = (element.matrix || new Matrix), + to2 = { + _: {transform: _.transform}, + getBBox: function () { + return element.getBBox(1); + } + }; + from[attr] = [ + m.a, + m.b, + m.c, + m.d, + m.e, + m.f + ]; + extractTransform(to2, to[attr]); + to[attr] = to2._.transform; + diff[attr] = [ + (to2.matrix.a - m.a) / ms, + (to2.matrix.b - m.b) / ms, + (to2.matrix.c - m.c) / ms, + (to2.matrix.d - m.d) / ms, + (to2.matrix.e - m.e) / ms, + (to2.matrix.f - m.f) / ms + ]; + // from[attr] = [_.sx, _.sy, _.deg, _.dx, _.dy]; + // var to2 = {_:{}, getBBox: function () { return element.getBBox(); }}; + // extractTransform(to2, to[attr]); + // diff[attr] = [ + // (to2._.sx - _.sx) / ms, + // (to2._.sy - _.sy) / ms, + // (to2._.deg - _.deg) / ms, + // (to2._.dx - _.dx) / ms, + // (to2._.dy - _.dy) / ms + // ]; + } + break; + case "csv": + var values = Str(params[attr])[split](separator), + from2 = Str(from[attr])[split](separator); + if (attr == "clip-rect") { + from[attr] = from2; + diff[attr] = []; + i = from2.length; + while (i--) { + diff[attr][i] = (values[i] - from[attr][i]) / ms; + } + } + to[attr] = values; + break; + default: + values = [][concat](params[attr]); + from2 = [][concat](from[attr]); + diff[attr] = []; + i = element.paper.customAttributes[attr].length; + while (i--) { + diff[attr][i] = ((values[i] || 0) - (from2[i] || 0)) / ms; + } + break; + } + } + } + var easing = params.easing, + easyeasy = R.easing_formulas[easing]; + if (!easyeasy) { + easyeasy = Str(easing).match(bezierrg); + if (easyeasy && easyeasy.length == 5) { + var curve = easyeasy; + easyeasy = function (t) { + return CubicBezierAtTime(t, +curve[1], +curve[2], +curve[3], +curve[4], ms); + }; + } else { + easyeasy = pipe; + } + } + timestamp = params.start || anim.start || +new Date; + e = { + anim: anim, + percent: percent, + timestamp: timestamp, + start: timestamp + (anim.del || 0), + status: 0, + initstatus: status || 0, + stop: false, + ms: ms, + easing: easyeasy, + from: from, + diff: diff, + to: to, + el: element, + callback: params.callback, + prev: prev, + next: next, + repeat: times || anim.times, + origin: element.attr(), + totalOrigin: totalOrigin + }; + animationElements.push(e); + if (status && !isInAnim && !isInAnimSet) { + e.stop = true; + e.start = new Date - ms * status; + if (animationElements.length == 1) { + return animation(); + } + } + if (isInAnimSet) { + e.start = new Date - e.ms * status; + } + animationElements.length == 1 && requestAnimFrame(animation); + } else { + isInAnim.initstatus = status; + isInAnim.start = new Date - isInAnim.ms * status; + } + eve("raphael.anim.start." + element.id, element, anim); + } + /*\ + * Raphael.animation + [ method ] + ** + * Creates an animation object that can be passed to the @Element.animate or @Element.animateWith methods. + * See also @Animation.delay and @Animation.repeat methods. + ** + > Parameters + ** + - params (object) final attributes for the element, see also @Element.attr + - ms (number) number of milliseconds for animation to run + - easing (string) #optional easing type. Accept one of @Raphael.easing_formulas or CSS format: `cubic‐bezier(XX, XX, XX, XX)` + - callback (function) #optional callback function. Will be called at the end of animation. + ** + = (object) @Animation + \*/ + R.animation = function (params, ms, easing, callback) { + if (params instanceof Animation) { + return params; + } + if (R.is(easing, "function") || !easing) { + callback = callback || easing || null; + easing = null; + } + params = Object(params); + ms = +ms || 0; + var p = {}, + json, + attr; + for (attr in params) if (params[has](attr) && toFloat(attr) != attr && toFloat(attr) + "%" != attr) { + json = true; + p[attr] = params[attr]; + } + if (!json) { + // if percent-like syntax is used and end-of-all animation callback used + if(callback){ + // find the last one + var lastKey = 0; + for(var i in params){ + var percent = toInt(i); + if(params[has](i) && percent > lastKey){ + lastKey = percent; + } + } + lastKey += '%'; + // if already defined callback in the last keyframe, skip + !params[lastKey].callback && (params[lastKey].callback = callback); + } + return new Animation(params, ms); + } else { + easing && (p.easing = easing); + callback && (p.callback = callback); + return new Animation({100: p}, ms); + } + }; + /*\ + * Element.animate + [ method ] + ** + * Creates and starts animation for given element. + ** + > Parameters + ** + - params (object) final attributes for the element, see also @Element.attr + - ms (number) number of milliseconds for animation to run + - easing (string) #optional easing type. Accept one of @Raphael.easing_formulas or CSS format: `cubic‐bezier(XX, XX, XX, XX)` + - callback (function) #optional callback function. Will be called at the end of animation. + * or + - animation (object) animation object, see @Raphael.animation + ** + = (object) original element + \*/ + elproto.animate = function (params, ms, easing, callback) { + var element = this; + if (element.removed) { + callback && callback.call(element); + return element; + } + var anim = params instanceof Animation ? params : R.animation(params, ms, easing, callback); + runAnimation(anim, element, anim.percents[0], null, element.attr()); + return element; + }; + /*\ + * Element.setTime + [ method ] + ** + * Sets the status of animation of the element in milliseconds. Similar to @Element.status method. + ** + > Parameters + ** + - anim (object) animation object + - value (number) number of milliseconds from the beginning of the animation + ** + = (object) original element if `value` is specified + * Note, that during animation following events are triggered: + * + * On each animation frame event `anim.frame.`, on start `anim.start.` and on end `anim.finish.`. + \*/ + elproto.setTime = function (anim, value) { + if (anim && value != null) { + this.status(anim, mmin(value, anim.ms) / anim.ms); + } + return this; + }; + /*\ + * Element.status + [ method ] + ** + * Gets or sets the status of animation of the element. + ** + > Parameters + ** + - anim (object) #optional animation object + - value (number) #optional 0 – 1. If specified, method works like a setter and sets the status of a given animation to the value. This will cause animation to jump to the given position. + ** + = (number) status + * or + = (array) status if `anim` is not specified. Array of objects in format: + o { + o anim: (object) animation object + o status: (number) status + o } + * or + = (object) original element if `value` is specified + \*/ + elproto.status = function (anim, value) { + var out = [], + i = 0, + len, + e; + if (value != null) { + runAnimation(anim, this, -1, mmin(value, 1)); + return this; + } else { + len = animationElements.length; + for (; i < len; i++) { + e = animationElements[i]; + if (e.el.id == this.id && (!anim || e.anim == anim)) { + if (anim) { + return e.status; + } + out.push({ + anim: e.anim, + status: e.status + }); + } + } + if (anim) { + return 0; + } + return out; + } + }; + /*\ + * Element.pause + [ method ] + ** + * Stops animation of the element with ability to resume it later on. + ** + > Parameters + ** + - anim (object) #optional animation object + ** + = (object) original element + \*/ + elproto.pause = function (anim) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.id == this.id && (!anim || animationElements[i].anim == anim)) { + if (eve("raphael.anim.pause." + this.id, this, animationElements[i].anim) !== false) { + animationElements[i].paused = true; + } + } + return this; + }; + /*\ + * Element.resume + [ method ] + ** + * Resumes animation if it was paused with @Element.pause method. + ** + > Parameters + ** + - anim (object) #optional animation object + ** + = (object) original element + \*/ + elproto.resume = function (anim) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.id == this.id && (!anim || animationElements[i].anim == anim)) { + var e = animationElements[i]; + if (eve("raphael.anim.resume." + this.id, this, e.anim) !== false) { + delete e.paused; + this.status(e.anim, e.status); + } + } + return this; + }; + /*\ + * Element.stop + [ method ] + ** + * Stops animation of the element. + ** + > Parameters + ** + - anim (object) #optional animation object + ** + = (object) original element + \*/ + elproto.stop = function (anim) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.id == this.id && (!anim || animationElements[i].anim == anim)) { + if (eve("raphael.anim.stop." + this.id, this, animationElements[i].anim) !== false) { + animationElements.splice(i--, 1); + } + } + return this; + }; + function stopAnimation(paper) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.paper == paper) { + animationElements.splice(i--, 1); + } + } + eve.on("raphael.remove", stopAnimation); + eve.on("raphael.clear", stopAnimation); + elproto.toString = function () { + return "Rapha\xebl\u2019s object"; + }; + + // Set + var Set = function (items) { + this.items = []; + this.length = 0; + this.type = "set"; + if (items) { + for (var i = 0, ii = items.length; i < ii; i++) { + if (items[i] && (items[i].constructor == elproto.constructor || items[i].constructor == Set)) { + this[this.items.length] = this.items[this.items.length] = items[i]; + this.length++; + } + } + } + }, + setproto = Set.prototype; + /*\ + * Set.push + [ method ] + ** + * Adds each argument to the current set. + = (object) original element + \*/ + setproto.push = function () { + var item, + len; + for (var i = 0, ii = arguments.length; i < ii; i++) { + item = arguments[i]; + if (item && (item.constructor == elproto.constructor || item.constructor == Set)) { + len = this.items.length; + this[len] = this.items[len] = item; + this.length++; + } + } + return this; + }; + /*\ + * Set.pop + [ method ] + ** + * Removes last element and returns it. + = (object) element + \*/ + setproto.pop = function () { + this.length && delete this[this.length--]; + return this.items.pop(); + }; + /*\ + * Set.forEach + [ method ] + ** + * Executes given function for each element in the set. + * + * If function returns `false` it will stop loop running. + ** + > Parameters + ** + - callback (function) function to run + - thisArg (object) context object for the callback + = (object) Set object + \*/ + setproto.forEach = function (callback, thisArg) { + for (var i = 0, ii = this.items.length; i < ii; i++) { + if (callback.call(thisArg, this.items[i], i) === false) { + return this; + } + } + return this; + }; + for (var method in elproto) if (elproto[has](method)) { + setproto[method] = (function (methodname) { + return function () { + var arg = arguments; + return this.forEach(function (el) { + el[methodname][apply](el, arg); + }); + }; + })(method); + } + setproto.attr = function (name, value) { + if (name && R.is(name, array) && R.is(name[0], "object")) { + for (var j = 0, jj = name.length; j < jj; j++) { + this.items[j].attr(name[j]); + } + } else { + for (var i = 0, ii = this.items.length; i < ii; i++) { + this.items[i].attr(name, value); + } + } + return this; + }; + /*\ + * Set.clear + [ method ] + ** + * Removes all elements from the set + \*/ + setproto.clear = function () { + while (this.length) { + this.pop(); + } + }; + /*\ + * Set.splice + [ method ] + ** + * Removes given element from the set + ** + > Parameters + ** + - index (number) position of the deletion + - count (number) number of element to remove + - insertion… (object) #optional elements to insert + = (object) set elements that were deleted + \*/ + setproto.splice = function (index, count, insertion) { + index = index < 0 ? mmax(this.length + index, 0) : index; + count = mmax(0, mmin(this.length - index, count)); + var tail = [], + todel = [], + args = [], + i; + for (i = 2; i < arguments.length; i++) { + args.push(arguments[i]); + } + for (i = 0; i < count; i++) { + todel.push(this[index + i]); + } + for (; i < this.length - index; i++) { + tail.push(this[index + i]); + } + var arglen = args.length; + for (i = 0; i < arglen + tail.length; i++) { + this.items[index + i] = this[index + i] = i < arglen ? args[i] : tail[i - arglen]; + } + i = this.items.length = this.length -= count - arglen; + while (this[i]) { + delete this[i++]; + } + return new Set(todel); + }; + /*\ + * Set.exclude + [ method ] + ** + * Removes given element from the set + ** + > Parameters + ** + - element (object) element to remove + = (boolean) `true` if object was found & removed from the set + \*/ + setproto.exclude = function (el) { + for (var i = 0, ii = this.length; i < ii; i++) if (this[i] == el) { + this.splice(i, 1); + return true; + } + }; + setproto.animate = function (params, ms, easing, callback) { + (R.is(easing, "function") || !easing) && (callback = easing || null); + var len = this.items.length, + i = len, + item, + set = this, + collector; + if (!len) { + return this; + } + callback && (collector = function () { + !--len && callback.call(set); + }); + easing = R.is(easing, string) ? easing : collector; + var anim = R.animation(params, ms, easing, collector); + item = this.items[--i].animate(anim); + while (i--) { + this.items[i] && !this.items[i].removed && this.items[i].animateWith(item, anim, anim); + (this.items[i] && !this.items[i].removed) || len--; + } + return this; + }; + setproto.insertAfter = function (el) { + var i = this.items.length; + while (i--) { + this.items[i].insertAfter(el); + } + return this; + }; + setproto.getBBox = function () { + var x = [], + y = [], + x2 = [], + y2 = []; + for (var i = this.items.length; i--;) if (!this.items[i].removed) { + var box = this.items[i].getBBox(); + x.push(box.x); + y.push(box.y); + x2.push(box.x + box.width); + y2.push(box.y + box.height); + } + x = mmin[apply](0, x); + y = mmin[apply](0, y); + x2 = mmax[apply](0, x2); + y2 = mmax[apply](0, y2); + return { + x: x, + y: y, + x2: x2, + y2: y2, + width: x2 - x, + height: y2 - y + }; + }; + setproto.clone = function (s) { + s = this.paper.set(); + for (var i = 0, ii = this.items.length; i < ii; i++) { + s.push(this.items[i].clone()); + } + return s; + }; + setproto.toString = function () { + return "Rapha\xebl\u2018s set"; + }; + + setproto.glow = function(glowConfig) { + var ret = this.paper.set(); + this.forEach(function(shape, index){ + var g = shape.glow(glowConfig); + if(g != null){ + g.forEach(function(shape2, index2){ + ret.push(shape2); + }); + } + }); + return ret; + }; + + + /*\ + * Set.isPointInside + [ method ] + ** + * Determine if given point is inside this set’s elements + ** + > Parameters + ** + - x (number) x coordinate of the point + - y (number) y coordinate of the point + = (boolean) `true` if point is inside any of the set's elements + \*/ + setproto.isPointInside = function (x, y) { + var isPointInside = false; + this.forEach(function (el) { + if (el.isPointInside(x, y)) { + isPointInside = true; + return false; // stop loop + } + }); + return isPointInside; + }; + + /*\ + * Raphael.registerFont + [ method ] + ** + * Adds given font to the registered set of fonts for Raphaël. Should be used as an internal call from within Cufón’s font file. + * Returns original parameter, so it could be used with chaining. + # More about Cufón and how to convert your font form TTF, OTF, etc to JavaScript file. + ** + > Parameters + ** + - font (object) the font to register + = (object) the font you passed in + > Usage + | Cufon.registerFont(Raphael.registerFont({…})); + \*/ + R.registerFont = function (font) { + if (!font.face) { + return font; + } + this.fonts = this.fonts || {}; + var fontcopy = { + w: font.w, + face: {}, + glyphs: {} + }, + family = font.face["font-family"]; + for (var prop in font.face) if (font.face[has](prop)) { + fontcopy.face[prop] = font.face[prop]; + } + if (this.fonts[family]) { + this.fonts[family].push(fontcopy); + } else { + this.fonts[family] = [fontcopy]; + } + if (!font.svg) { + fontcopy.face["units-per-em"] = toInt(font.face["units-per-em"], 10); + for (var glyph in font.glyphs) if (font.glyphs[has](glyph)) { + var path = font.glyphs[glyph]; + fontcopy.glyphs[glyph] = { + w: path.w, + k: {}, + d: path.d && "M" + path.d.replace(/[mlcxtrv]/g, function (command) { + return {l: "L", c: "C", x: "z", t: "m", r: "l", v: "c"}[command] || "M"; + }) + "z" + }; + if (path.k) { + for (var k in path.k) if (path[has](k)) { + fontcopy.glyphs[glyph].k[k] = path.k[k]; + } + } + } + } + return font; + }; + /*\ + * Paper.getFont + [ method ] + ** + * Finds font object in the registered fonts by given parameters. You could specify only one word from the font name, like “Myriad” for “Myriad Pro”. + ** + > Parameters + ** + - family (string) font family name or any word from it + - weight (string) #optional font weight + - style (string) #optional font style + - stretch (string) #optional font stretch + = (object) the font object + > Usage + | paper.print(100, 100, "Test string", paper.getFont("Times", 800), 30); + \*/ + paperproto.getFont = function (family, weight, style, stretch) { + stretch = stretch || "normal"; + style = style || "normal"; + weight = +weight || {normal: 400, bold: 700, lighter: 300, bolder: 800}[weight] || 400; + if (!R.fonts) { + return; + } + var font = R.fonts[family]; + if (!font) { + var name = new RegExp("(^|\\s)" + family.replace(/[^\w\d\s+!~.:_-]/g, E) + "(\\s|$)", "i"); + for (var fontName in R.fonts) if (R.fonts[has](fontName)) { + if (name.test(fontName)) { + font = R.fonts[fontName]; + break; + } + } + } + var thefont; + if (font) { + for (var i = 0, ii = font.length; i < ii; i++) { + thefont = font[i]; + if (thefont.face["font-weight"] == weight && (thefont.face["font-style"] == style || !thefont.face["font-style"]) && thefont.face["font-stretch"] == stretch) { + break; + } + } + } + return thefont; + }; + /*\ + * Paper.print + [ method ] + ** + * Creates path that represent given text written using given font at given position with given size. + * Result of the method is path element that contains whole text as a separate path. + ** + > Parameters + ** + - x (number) x position of the text + - y (number) y position of the text + - string (string) text to print + - font (object) font object, see @Paper.getFont + - size (number) #optional size of the font, default is `16` + - origin (string) #optional could be `"baseline"` or `"middle"`, default is `"middle"` + - letter_spacing (number) #optional number in range `-1..1`, default is `0` + - line_spacing (number) #optional number in range `1..3`, default is `1` + = (object) resulting path element, which consist of all letters + > Usage + | var txt = r.print(10, 50, "print", r.getFont("Museo"), 30).attr({fill: "#fff"}); + \*/ + paperproto.print = function (x, y, string, font, size, origin, letter_spacing, line_spacing) { + origin = origin || "middle"; // baseline|middle + letter_spacing = mmax(mmin(letter_spacing || 0, 1), -1); + line_spacing = mmax(mmin(line_spacing || 1, 3), 1); + var letters = Str(string)[split](E), + shift = 0, + notfirst = 0, + path = E, + scale; + R.is(font, "string") && (font = this.getFont(font)); + if (font) { + scale = (size || 16) / font.face["units-per-em"]; + var bb = font.face.bbox[split](separator), + top = +bb[0], + lineHeight = bb[3] - bb[1], + shifty = 0, + height = +bb[1] + (origin == "baseline" ? lineHeight + (+font.face.descent) : lineHeight / 2); + for (var i = 0, ii = letters.length; i < ii; i++) { + if (letters[i] == "\n") { + shift = 0; + curr = 0; + notfirst = 0; + shifty += lineHeight * line_spacing; + } else { + var prev = notfirst && font.glyphs[letters[i - 1]] || {}, + curr = font.glyphs[letters[i]]; + shift += notfirst ? (prev.w || font.w) + (prev.k && prev.k[letters[i]] || 0) + (font.w * letter_spacing) : 0; + notfirst = 1; + } + if (curr && curr.d) { + path += R.transformPath(curr.d, ["t", shift * scale, shifty * scale, "s", scale, scale, top, height, "t", (x - top) / scale, (y - height) / scale]); + } + } + } + return this.path(path).attr({ + fill: "#000", + stroke: "none" + }); + }; + + /*\ + * Paper.add + [ method ] + ** + * Imports elements in JSON array in format `{type: type, }` + ** + > Parameters + ** + - json (array) + = (object) resulting set of imported elements + > Usage + | paper.add([ + | { + | type: "circle", + | cx: 10, + | cy: 10, + | r: 5 + | }, + | { + | type: "rect", + | x: 10, + | y: 10, + | width: 10, + | height: 10, + | fill: "#fc0" + | } + | ]); + \*/ + paperproto.add = function (json) { + if (R.is(json, "array")) { + var res = this.set(), + i = 0, + ii = json.length, + j; + for (; i < ii; i++) { + j = json[i] || {}; + elements[has](j.type) && res.push(this[j.type]().attr(j)); + } + } + return res; + }; + + /*\ + * Raphael.format + [ method ] + ** + * Simple format function. Replaces construction of type “`{}`” to the corresponding argument. + ** + > Parameters + ** + - token (string) string to format + - … (string) rest of arguments will be treated as parameters for replacement + = (string) formated string + > Usage + | var x = 10, + | y = 20, + | width = 40, + | height = 50; + | // this will draw a rectangular shape equivalent to "M10,20h40v50h-40z" + | paper.path(Raphael.format("M{0},{1}h{2}v{3}h{4}z", x, y, width, height, -width)); + \*/ + R.format = function (token, params) { + var args = R.is(params, array) ? [0][concat](params) : arguments; + token && R.is(token, string) && args.length - 1 && (token = token.replace(formatrg, function (str, i) { + return args[++i] == null ? E : args[i]; + })); + return token || E; + }; + /*\ + * Raphael.fullfill + [ method ] + ** + * A little bit more advanced format function than @Raphael.format. Replaces construction of type “`{}`” to the corresponding argument. + ** + > Parameters + ** + - token (string) string to format + - json (object) object which properties will be used as a replacement + = (string) formated string + > Usage + | // this will draw a rectangular shape equivalent to "M10,20h40v50h-40z" + | paper.path(Raphael.fullfill("M{x},{y}h{dim.width}v{dim.height}h{dim['negative width']}z", { + | x: 10, + | y: 20, + | dim: { + | width: 40, + | height: 50, + | "negative width": -40 + | } + | })); + \*/ + R.fullfill = (function () { + var tokenRegex = /\{([^\}]+)\}/g, + objNotationRegex = /(?:(?:^|\.)(.+?)(?=\[|\.|$|\()|\[('|")(.+?)\2\])(\(\))?/g, // matches .xxxxx or ["xxxxx"] to run over object properties + replacer = function (all, key, obj) { + var res = obj; + key.replace(objNotationRegex, function (all, name, quote, quotedName, isFunc) { + name = name || quotedName; + if (res) { + if (name in res) { + res = res[name]; + } + typeof res == "function" && isFunc && (res = res()); + } + }); + res = (res == null || res == obj ? all : res) + ""; + return res; + }; + return function (str, obj) { + return String(str).replace(tokenRegex, function (all, key) { + return replacer(all, key, obj); + }); + }; + })(); + /*\ + * Raphael.ninja + [ method ] + ** + * If you want to leave no trace of Raphaël (Well, Raphaël creates only one global variable `Raphael`, but anyway.) You can use `ninja` method. + * Beware, that in this case plugins could stop working, because they are depending on global variable existance. + ** + = (object) Raphael object + > Usage + | (function (local_raphael) { + | var paper = local_raphael(10, 10, 320, 200); + | … + | })(Raphael.ninja()); + \*/ + R.ninja = function () { + oldRaphael.was ? (g.win.Raphael = oldRaphael.is) : delete Raphael; + return R; + }; + /*\ + * Raphael.st + [ property (object) ] + ** + * You can add your own method to elements and sets. It is wise to add a set method for each element method + * you added, so you will be able to call the same method on sets too. + ** + * See also @Raphael.el. + > Usage + | Raphael.el.red = function () { + | this.attr({fill: "#f00"}); + | }; + | Raphael.st.red = function () { + | this.forEach(function (el) { + | el.red(); + | }); + | }; + | // then use it + | paper.set(paper.circle(100, 100, 20), paper.circle(110, 100, 20)).red(); + \*/ + R.st = setproto; + + eve.on("raphael.DOMload", function () { + loaded = true; + }); + + // Firefox <3.6 fix: http://webreflection.blogspot.com/2009/11/195-chars-to-help-lazy-loading.html + (function (doc, loaded, f) { + if (doc.readyState == null && doc.addEventListener){ + doc.addEventListener(loaded, f = function () { + doc.removeEventListener(loaded, f, false); + doc.readyState = "complete"; + }, false); + doc.readyState = "loading"; + } + function isLoaded() { + (/in/).test(doc.readyState) ? setTimeout(isLoaded, 9) : R.eve("raphael.DOMload"); + } + isLoaded(); + })(document, "DOMContentLoaded"); + +// ┌─────────────────────────────────────────────────────────────────────┐ \\ +// │ Raphaël - JavaScript Vector Library │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ SVG Module │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright (c) 2008-2011 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright (c) 2008-2011 Sencha Labs (http://sencha.com) │ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license. │ \\ +// └─────────────────────────────────────────────────────────────────────┘ \\ + +(function(){ + if (!R.svg) { + return; + } + var has = "hasOwnProperty", + Str = String, + toFloat = parseFloat, + toInt = parseInt, + math = Math, + mmax = math.max, + abs = math.abs, + pow = math.pow, + separator = /[, ]+/, + eve = R.eve, + E = "", + S = " "; + var xlink = "http://www.w3.org/1999/xlink", + markers = { + block: "M5,0 0,2.5 5,5z", + classic: "M5,0 0,2.5 5,5 3.5,3 3.5,2z", + diamond: "M2.5,0 5,2.5 2.5,5 0,2.5z", + open: "M6,1 1,3.5 6,6", + oval: "M2.5,0A2.5,2.5,0,0,1,2.5,5 2.5,2.5,0,0,1,2.5,0z" + }, + markerCounter = {}; + R.toString = function () { + return "Your browser supports SVG.\nYou are running Rapha\xebl " + this.version; + }; + var $ = function (el, attr) { + if (attr) { + if (typeof el == "string") { + el = $(el); + } + for (var key in attr) if (attr[has](key)) { + if (key.substring(0, 6) == "xlink:") { + el.setAttributeNS(xlink, key.substring(6), Str(attr[key])); + } else { + el.setAttribute(key, Str(attr[key])); + } + } + } else { + el = R._g.doc.createElementNS("http://www.w3.org/2000/svg", el); + el.style && (el.style.webkitTapHighlightColor = "rgba(0,0,0,0)"); + } + return el; + }, + addGradientFill = function (element, gradient) { + var type = "linear", + id = element.id + gradient, + fx = .5, fy = .5, + o = element.node, + SVG = element.paper, + s = o.style, + el = R._g.doc.getElementById(id); + if (!el) { + gradient = Str(gradient).replace(R._radial_gradient, function (all, _fx, _fy) { + type = "radial"; + if (_fx && _fy) { + fx = toFloat(_fx); + fy = toFloat(_fy); + var dir = ((fy > .5) * 2 - 1); + pow(fx - .5, 2) + pow(fy - .5, 2) > .25 && + (fy = math.sqrt(.25 - pow(fx - .5, 2)) * dir + .5) && + fy != .5 && + (fy = fy.toFixed(5) - 1e-5 * dir); + } + return E; + }); + gradient = gradient.split(/\s*\-\s*/); + if (type == "linear") { + var angle = gradient.shift(); + angle = -toFloat(angle); + if (isNaN(angle)) { + return null; + } + var vector = [0, 0, math.cos(R.rad(angle)), math.sin(R.rad(angle))], + max = 1 / (mmax(abs(vector[2]), abs(vector[3])) || 1); + vector[2] *= max; + vector[3] *= max; + if (vector[2] < 0) { + vector[0] = -vector[2]; + vector[2] = 0; + } + if (vector[3] < 0) { + vector[1] = -vector[3]; + vector[3] = 0; + } + } + var dots = R._parseDots(gradient); + if (!dots) { + return null; + } + id = id.replace(/[\(\)\s,\xb0#]/g, "_"); + + if (element.gradient && id != element.gradient.id) { + SVG.defs.removeChild(element.gradient); + delete element.gradient; + } + + if (!element.gradient) { + el = $(type + "Gradient", {id: id}); + element.gradient = el; + $(el, type == "radial" ? { + fx: fx, + fy: fy + } : { + x1: vector[0], + y1: vector[1], + x2: vector[2], + y2: vector[3], + gradientTransform: element.matrix.invert() + }); + SVG.defs.appendChild(el); + for (var i = 0, ii = dots.length; i < ii; i++) { + el.appendChild($("stop", { + offset: dots[i].offset ? dots[i].offset : i ? "100%" : "0%", + "stop-color": dots[i].color || "#fff" + })); + } + } + } + $(o, { + fill: "url('" + document.location + "#" + id + "')", + opacity: 1, + "fill-opacity": 1 + }); + s.fill = E; + s.opacity = 1; + s.fillOpacity = 1; + return 1; + }, + updatePosition = function (o) { + var bbox = o.getBBox(1); + $(o.pattern, {patternTransform: o.matrix.invert() + " translate(" + bbox.x + "," + bbox.y + ")"}); + }, + addArrow = function (o, value, isEnd) { + if (o.type == "path") { + var values = Str(value).toLowerCase().split("-"), + p = o.paper, + se = isEnd ? "end" : "start", + node = o.node, + attrs = o.attrs, + stroke = attrs["stroke-width"], + i = values.length, + type = "classic", + from, + to, + dx, + refX, + attr, + w = 3, + h = 3, + t = 5; + while (i--) { + switch (values[i]) { + case "block": + case "classic": + case "oval": + case "diamond": + case "open": + case "none": + type = values[i]; + break; + case "wide": h = 5; break; + case "narrow": h = 2; break; + case "long": w = 5; break; + case "short": w = 2; break; + } + } + if (type == "open") { + w += 2; + h += 2; + t += 2; + dx = 1; + refX = isEnd ? 4 : 1; + attr = { + fill: "none", + stroke: attrs.stroke + }; + } else { + refX = dx = w / 2; + attr = { + fill: attrs.stroke, + stroke: "none" + }; + } + if (o._.arrows) { + if (isEnd) { + o._.arrows.endPath && markerCounter[o._.arrows.endPath]--; + o._.arrows.endMarker && markerCounter[o._.arrows.endMarker]--; + } else { + o._.arrows.startPath && markerCounter[o._.arrows.startPath]--; + o._.arrows.startMarker && markerCounter[o._.arrows.startMarker]--; + } + } else { + o._.arrows = {}; + } + if (type != "none") { + var pathId = "raphael-marker-" + type, + markerId = "raphael-marker-" + se + type + w + h + "-obj" + o.id; + if (!R._g.doc.getElementById(pathId)) { + p.defs.appendChild($($("path"), { + "stroke-linecap": "round", + d: markers[type], + id: pathId + })); + markerCounter[pathId] = 1; + } else { + markerCounter[pathId]++; + } + var marker = R._g.doc.getElementById(markerId), + use; + if (!marker) { + marker = $($("marker"), { + id: markerId, + markerHeight: h, + markerWidth: w, + orient: "auto", + refX: refX, + refY: h / 2 + }); + use = $($("use"), { + "xlink:href": "#" + pathId, + transform: (isEnd ? "rotate(180 " + w / 2 + " " + h / 2 + ") " : E) + "scale(" + w / t + "," + h / t + ")", + "stroke-width": (1 / ((w / t + h / t) / 2)).toFixed(4) + }); + marker.appendChild(use); + p.defs.appendChild(marker); + markerCounter[markerId] = 1; + } else { + markerCounter[markerId]++; + use = marker.getElementsByTagName("use")[0]; + } + $(use, attr); + var delta = dx * (type != "diamond" && type != "oval"); + if (isEnd) { + from = o._.arrows.startdx * stroke || 0; + to = R.getTotalLength(attrs.path) - delta * stroke; + } else { + from = delta * stroke; + to = R.getTotalLength(attrs.path) - (o._.arrows.enddx * stroke || 0); + } + attr = {}; + attr["marker-" + se] = "url(#" + markerId + ")"; + if (to || from) { + attr.d = R.getSubpath(attrs.path, from, to); + } + $(node, attr); + o._.arrows[se + "Path"] = pathId; + o._.arrows[se + "Marker"] = markerId; + o._.arrows[se + "dx"] = delta; + o._.arrows[se + "Type"] = type; + o._.arrows[se + "String"] = value; + } else { + if (isEnd) { + from = o._.arrows.startdx * stroke || 0; + to = R.getTotalLength(attrs.path) - from; + } else { + from = 0; + to = R.getTotalLength(attrs.path) - (o._.arrows.enddx * stroke || 0); + } + o._.arrows[se + "Path"] && $(node, {d: R.getSubpath(attrs.path, from, to)}); + delete o._.arrows[se + "Path"]; + delete o._.arrows[se + "Marker"]; + delete o._.arrows[se + "dx"]; + delete o._.arrows[se + "Type"]; + delete o._.arrows[se + "String"]; + } + for (attr in markerCounter) if (markerCounter[has](attr) && !markerCounter[attr]) { + var item = R._g.doc.getElementById(attr); + item && item.parentNode.removeChild(item); + } + } + }, + dasharray = { + "": [0], + "none": [0], + "-": [3, 1], + ".": [1, 1], + "-.": [3, 1, 1, 1], + "-..": [3, 1, 1, 1, 1, 1], + ". ": [1, 3], + "- ": [4, 3], + "--": [8, 3], + "- .": [4, 3, 1, 3], + "--.": [8, 3, 1, 3], + "--..": [8, 3, 1, 3, 1, 3] + }, + addDashes = function (o, value, params) { + value = dasharray[Str(value).toLowerCase()]; + if (value) { + var width = o.attrs["stroke-width"] || "1", + butt = {round: width, square: width, butt: 0}[o.attrs["stroke-linecap"] || params["stroke-linecap"]] || 0, + dashes = [], + i = value.length; + while (i--) { + dashes[i] = value[i] * width + ((i % 2) ? 1 : -1) * butt; + } + $(o.node, {"stroke-dasharray": dashes.join(",")}); + } + }, + setFillAndStroke = function (o, params) { + var node = o.node, + attrs = o.attrs, + vis = node.style.visibility; + node.style.visibility = "hidden"; + for (var att in params) { + if (params[has](att)) { + if (!R._availableAttrs[has](att)) { + continue; + } + var value = params[att]; + attrs[att] = value; + switch (att) { + case "blur": + o.blur(value); + break; + case "title": + var title = node.getElementsByTagName("title"); + + // Use the existing . + if (title.length && (title = title[0])) { + title.firstChild.nodeValue = value; + } else { + title = $("title"); + var val = R._g.doc.createTextNode(value); + title.appendChild(val); + node.appendChild(title); + } + break; + case "href": + case "target": + var pn = node.parentNode; + if (pn.tagName.toLowerCase() != "a") { + var hl = $("a"); + pn.insertBefore(hl, node); + hl.appendChild(node); + pn = hl; + } + if (att == "target") { + pn.setAttributeNS(xlink, "show", value == "blank" ? "new" : value); + } else { + pn.setAttributeNS(xlink, att, value); + } + break; + case "cursor": + node.style.cursor = value; + break; + case "transform": + o.transform(value); + break; + case "arrow-start": + addArrow(o, value); + break; + case "arrow-end": + addArrow(o, value, 1); + break; + case "clip-rect": + var rect = Str(value).split(separator); + if (rect.length == 4) { + o.clip && o.clip.parentNode.parentNode.removeChild(o.clip.parentNode); + var el = $("clipPath"), + rc = $("rect"); + el.id = R.createUUID(); + $(rc, { + x: rect[0], + y: rect[1], + width: rect[2], + height: rect[3] + }); + el.appendChild(rc); + o.paper.defs.appendChild(el); + $(node, {"clip-path": "url(#" + el.id + ")"}); + o.clip = rc; + } + if (!value) { + var path = node.getAttribute("clip-path"); + if (path) { + var clip = R._g.doc.getElementById(path.replace(/(^url\(#|\)$)/g, E)); + clip && clip.parentNode.removeChild(clip); + $(node, {"clip-path": E}); + delete o.clip; + } + } + break; + case "path": + if (o.type == "path") { + $(node, {d: value ? attrs.path = R._pathToAbsolute(value) : "M0,0"}); + o._.dirty = 1; + if (o._.arrows) { + "startString" in o._.arrows && addArrow(o, o._.arrows.startString); + "endString" in o._.arrows && addArrow(o, o._.arrows.endString, 1); + } + } + break; + case "width": + node.setAttribute(att, value); + o._.dirty = 1; + if (attrs.fx) { + att = "x"; + value = attrs.x; + } else { + break; + } + case "x": + if (attrs.fx) { + value = -attrs.x - (attrs.width || 0); + } + case "rx": + if (att == "rx" && o.type == "rect") { + break; + } + case "cx": + node.setAttribute(att, value); + o.pattern && updatePosition(o); + o._.dirty = 1; + break; + case "height": + node.setAttribute(att, value); + o._.dirty = 1; + if (attrs.fy) { + att = "y"; + value = attrs.y; + } else { + break; + } + case "y": + if (attrs.fy) { + value = -attrs.y - (attrs.height || 0); + } + case "ry": + if (att == "ry" && o.type == "rect") { + break; + } + case "cy": + node.setAttribute(att, value); + o.pattern && updatePosition(o); + o._.dirty = 1; + break; + case "r": + if (o.type == "rect") { + $(node, {rx: value, ry: value}); + } else { + node.setAttribute(att, value); + } + o._.dirty = 1; + break; + case "src": + if (o.type == "image") { + node.setAttributeNS(xlink, "href", value); + } + break; + case "stroke-width": + if (o._.sx != 1 || o._.sy != 1) { + value /= mmax(abs(o._.sx), abs(o._.sy)) || 1; + } + node.setAttribute(att, value); + if (attrs["stroke-dasharray"]) { + addDashes(o, attrs["stroke-dasharray"], params); + } + if (o._.arrows) { + "startString" in o._.arrows && addArrow(o, o._.arrows.startString); + "endString" in o._.arrows && addArrow(o, o._.arrows.endString, 1); + } + break; + case "stroke-dasharray": + addDashes(o, value, params); + break; + case "fill": + var isURL = Str(value).match(R._ISURL); + if (isURL) { + el = $("pattern"); + var ig = $("image"); + el.id = R.createUUID(); + $(el, {x: 0, y: 0, patternUnits: "userSpaceOnUse", height: 1, width: 1}); + $(ig, {x: 0, y: 0, "xlink:href": isURL[1]}); + el.appendChild(ig); + + (function (el) { + R._preload(isURL[1], function () { + var w = this.offsetWidth, + h = this.offsetHeight; + $(el, {width: w, height: h}); + $(ig, {width: w, height: h}); + o.paper.safari(); + }); + })(el); + o.paper.defs.appendChild(el); + $(node, {fill: "url(#" + el.id + ")"}); + o.pattern = el; + o.pattern && updatePosition(o); + break; + } + var clr = R.getRGB(value); + if (!clr.error) { + delete params.gradient; + delete attrs.gradient; + !R.is(attrs.opacity, "undefined") && + R.is(params.opacity, "undefined") && + $(node, {opacity: attrs.opacity}); + !R.is(attrs["fill-opacity"], "undefined") && + R.is(params["fill-opacity"], "undefined") && + $(node, {"fill-opacity": attrs["fill-opacity"]}); + } else if ((o.type == "circle" || o.type == "ellipse" || Str(value).charAt() != "r") && addGradientFill(o, value)) { + if ("opacity" in attrs || "fill-opacity" in attrs) { + var gradient = R._g.doc.getElementById(node.getAttribute("fill").replace(/^url\(#|\)$/g, E)); + if (gradient) { + var stops = gradient.getElementsByTagName("stop"); + $(stops[stops.length - 1], {"stop-opacity": ("opacity" in attrs ? attrs.opacity : 1) * ("fill-opacity" in attrs ? attrs["fill-opacity"] : 1)}); + } + } + attrs.gradient = value; + attrs.fill = "none"; + break; + } + clr[has]("opacity") && $(node, {"fill-opacity": clr.opacity > 1 ? clr.opacity / 100 : clr.opacity}); + case "stroke": + clr = R.getRGB(value); + node.setAttribute(att, clr.hex); + att == "stroke" && clr[has]("opacity") && $(node, {"stroke-opacity": clr.opacity > 1 ? clr.opacity / 100 : clr.opacity}); + if (att == "stroke" && o._.arrows) { + "startString" in o._.arrows && addArrow(o, o._.arrows.startString); + "endString" in o._.arrows && addArrow(o, o._.arrows.endString, 1); + } + break; + case "gradient": + (o.type == "circle" || o.type == "ellipse" || Str(value).charAt() != "r") && addGradientFill(o, value); + break; + case "opacity": + if (attrs.gradient && !attrs[has]("stroke-opacity")) { + $(node, {"stroke-opacity": value > 1 ? value / 100 : value}); + } + // fall + case "fill-opacity": + if (attrs.gradient) { + gradient = R._g.doc.getElementById(node.getAttribute("fill").replace(/^url\(#|\)$/g, E)); + if (gradient) { + stops = gradient.getElementsByTagName("stop"); + $(stops[stops.length - 1], {"stop-opacity": value}); + } + break; + } + default: + att == "font-size" && (value = toInt(value, 10) + "px"); + var cssrule = att.replace(/(\-.)/g, function (w) { + return w.substring(1).toUpperCase(); + }); + node.style[cssrule] = value; + o._.dirty = 1; + node.setAttribute(att, value); + break; + } + } + } + + tuneText(o, params); + node.style.visibility = vis; + }, + leading = 1.2, + tuneText = function (el, params) { + if (el.type != "text" || !(params[has]("text") || params[has]("font") || params[has]("font-size") || params[has]("x") || params[has]("y"))) { + return; + } + var a = el.attrs, + node = el.node, + fontSize = node.firstChild ? toInt(R._g.doc.defaultView.getComputedStyle(node.firstChild, E).getPropertyValue("font-size"), 10) : 10; + + if (params[has]("text")) { + a.text = params.text; + while (node.firstChild) { + node.removeChild(node.firstChild); + } + var texts = Str(params.text).split("\n"), + tspans = [], + tspan; + for (var i = 0, ii = texts.length; i < ii; i++) { + tspan = $("tspan"); + i && $(tspan, {dy: fontSize * leading, x: a.x}); + tspan.appendChild(R._g.doc.createTextNode(texts[i])); + node.appendChild(tspan); + tspans[i] = tspan; + } + } else { + tspans = node.getElementsByTagName("tspan"); + for (i = 0, ii = tspans.length; i < ii; i++) if (i) { + $(tspans[i], {dy: fontSize * leading, x: a.x}); + } else { + $(tspans[0], {dy: 0}); + } + } + $(node, {x: a.x, y: a.y}); + el._.dirty = 1; + var bb = el._getBBox(), + dif = a.y - (bb.y + bb.height / 2); + dif && R.is(dif, "finite") && $(tspans[0], {dy: dif}); + }, + getRealNode = function (node) { + if (node.parentNode && node.parentNode.tagName.toLowerCase() === "a") { + return node.parentNode; + } else { + return node; + } + }, + Element = function (node, svg) { + var X = 0, + Y = 0; + /*\ + * Element.node + [ property (object) ] + ** + * Gives you a reference to the DOM object, so you can assign event handlers or just mess around. + ** + * Note: Don’t mess with it. + > Usage + | // draw a circle at coordinate 10,10 with radius of 10 + | var c = paper.circle(10, 10, 10); + | c.node.onclick = function () { + | c.attr("fill", "red"); + | }; + \*/ + this[0] = this.node = node; + /*\ + * Element.raphael + [ property (object) ] + ** + * Internal reference to @Raphael object. In case it is not available. + > Usage + | Raphael.el.red = function () { + | var hsb = this.paper.raphael.rgb2hsb(this.attr("fill")); + | hsb.h = 1; + | this.attr({fill: this.paper.raphael.hsb2rgb(hsb).hex}); + | } + \*/ + node.raphael = true; + /*\ + * Element.id + [ property (number) ] + ** + * Unique id of the element. Especially useful when you want to listen to events of the element, + * because all events are fired in format `<module>.<action>.<id>`. Also useful for @Paper.getById method. + \*/ + this.id = R._oid++; + node.raphaelid = this.id; + this.matrix = R.matrix(); + this.realPath = null; + /*\ + * Element.paper + [ property (object) ] + ** + * Internal reference to “paper” where object drawn. Mainly for use in plugins and element extensions. + > Usage + | Raphael.el.cross = function () { + | this.attr({fill: "red"}); + | this.paper.path("M10,10L50,50M50,10L10,50") + | .attr({stroke: "red"}); + | } + \*/ + this.paper = svg; + this.attrs = this.attrs || {}; + this._ = { + transform: [], + sx: 1, + sy: 1, + deg: 0, + dx: 0, + dy: 0, + dirty: 1 + }; + !svg.bottom && (svg.bottom = this); + /*\ + * Element.prev + [ property (object) ] + ** + * Reference to the previous element in the hierarchy. + \*/ + this.prev = svg.top; + svg.top && (svg.top.next = this); + svg.top = this; + /*\ + * Element.next + [ property (object) ] + ** + * Reference to the next element in the hierarchy. + \*/ + this.next = null; + }, + elproto = R.el; + + Element.prototype = elproto; + elproto.constructor = Element; + + R._engine.path = function (pathString, SVG) { + var el = $("path"); + SVG.canvas && SVG.canvas.appendChild(el); + var p = new Element(el, SVG); + p.type = "path"; + setFillAndStroke(p, { + fill: "none", + stroke: "#000", + path: pathString + }); + return p; + }; + /*\ + * Element.rotate + [ method ] + ** + * Deprecated! Use @Element.transform instead. + * Adds rotation by given angle around given point to the list of + * transformations of the element. + > Parameters + - deg (number) angle in degrees + - cx (number) #optional x coordinate of the centre of rotation + - cy (number) #optional y coordinate of the centre of rotation + * If cx & cy aren’t specified centre of the shape is used as a point of rotation. + = (object) @Element + \*/ + elproto.rotate = function (deg, cx, cy) { + if (this.removed) { + return this; + } + deg = Str(deg).split(separator); + if (deg.length - 1) { + cx = toFloat(deg[1]); + cy = toFloat(deg[2]); + } + deg = toFloat(deg[0]); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + cx = bbox.x + bbox.width / 2; + cy = bbox.y + bbox.height / 2; + } + this.transform(this._.transform.concat([["r", deg, cx, cy]])); + return this; + }; + /*\ + * Element.scale + [ method ] + ** + * Deprecated! Use @Element.transform instead. + * Adds scale by given amount relative to given point to the list of + * transformations of the element. + > Parameters + - sx (number) horisontal scale amount + - sy (number) vertical scale amount + - cx (number) #optional x coordinate of the centre of scale + - cy (number) #optional y coordinate of the centre of scale + * If cx & cy aren’t specified centre of the shape is used instead. + = (object) @Element + \*/ + elproto.scale = function (sx, sy, cx, cy) { + if (this.removed) { + return this; + } + sx = Str(sx).split(separator); + if (sx.length - 1) { + sy = toFloat(sx[1]); + cx = toFloat(sx[2]); + cy = toFloat(sx[3]); + } + sx = toFloat(sx[0]); + (sy == null) && (sy = sx); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + } + cx = cx == null ? bbox.x + bbox.width / 2 : cx; + cy = cy == null ? bbox.y + bbox.height / 2 : cy; + this.transform(this._.transform.concat([["s", sx, sy, cx, cy]])); + return this; + }; + /*\ + * Element.translate + [ method ] + ** + * Deprecated! Use @Element.transform instead. + * Adds translation by given amount to the list of transformations of the element. + > Parameters + - dx (number) horisontal shift + - dy (number) vertical shift + = (object) @Element + \*/ + elproto.translate = function (dx, dy) { + if (this.removed) { + return this; + } + dx = Str(dx).split(separator); + if (dx.length - 1) { + dy = toFloat(dx[1]); + } + dx = toFloat(dx[0]) || 0; + dy = +dy || 0; + this.transform(this._.transform.concat([["t", dx, dy]])); + return this; + }; + /*\ + * Element.transform + [ method ] + ** + * Adds transformation to the element which is separate to other attributes, + * i.e. translation doesn’t change `x` or `y` of the rectange. The format + * of transformation string is similar to the path string syntax: + | "t100,100r30,100,100s2,2,100,100r45s1.5" + * Each letter is a command. There are four commands: `t` is for translate, `r` is for rotate, `s` is for + * scale and `m` is for matrix. + * + * There are also alternative “absolute” translation, rotation and scale: `T`, `R` and `S`. They will not take previous transformation into account. For example, `...T100,0` will always move element 100 px horisontally, while `...t100,0` could move it vertically if there is `r90` before. Just compare results of `r90t100,0` and `r90T100,0`. + * + * So, the example line above could be read like “translate by 100, 100; rotate 30° around 100, 100; scale twice around 100, 100; + * rotate 45° around centre; scale 1.5 times relative to centre”. As you can see rotate and scale commands have origin + * coordinates as optional parameters, the default is the centre point of the element. + * Matrix accepts six parameters. + > Usage + | var el = paper.rect(10, 20, 300, 200); + | // translate 100, 100, rotate 45°, translate -100, 0 + | el.transform("t100,100r45t-100,0"); + | // if you want you can append or prepend transformations + | el.transform("...t50,50"); + | el.transform("s2..."); + | // or even wrap + | el.transform("t50,50...t-50-50"); + | // to reset transformation call method with empty string + | el.transform(""); + | // to get current value call it without parameters + | console.log(el.transform()); + > Parameters + - tstr (string) #optional transformation string + * If tstr isn’t specified + = (string) current transformation string + * else + = (object) @Element + \*/ + elproto.transform = function (tstr) { + var _ = this._; + if (tstr == null) { + return _.transform; + } + R._extractTransform(this, tstr); + + this.clip && $(this.clip, {transform: this.matrix.invert()}); + this.pattern && updatePosition(this); + this.node && $(this.node, {transform: this.matrix}); + + if (_.sx != 1 || _.sy != 1) { + var sw = this.attrs[has]("stroke-width") ? this.attrs["stroke-width"] : 1; + this.attr({"stroke-width": sw}); + } + + return this; + }; + /*\ + * Element.hide + [ method ] + ** + * Makes element invisible. See @Element.show. + = (object) @Element + \*/ + elproto.hide = function () { + !this.removed && this.paper.safari(this.node.style.display = "none"); + return this; + }; + /*\ + * Element.show + [ method ] + ** + * Makes element visible. See @Element.hide. + = (object) @Element + \*/ + elproto.show = function () { + !this.removed && this.paper.safari(this.node.style.display = ""); + return this; + }; + /*\ + * Element.remove + [ method ] + ** + * Removes element from the paper. + \*/ + elproto.remove = function () { + var node = getRealNode(this.node); + if (this.removed || !node.parentNode) { + return; + } + var paper = this.paper; + paper.__set__ && paper.__set__.exclude(this); + eve.unbind("raphael.*.*." + this.id); + if (this.gradient) { + paper.defs.removeChild(this.gradient); + } + R._tear(this, paper); + + node.parentNode.removeChild(node); + + // Remove custom data for element + this.removeData(); + + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + this.removed = true; + }; + elproto._getBBox = function () { + if (this.node.style.display == "none") { + this.show(); + var hide = true; + } + var canvasHidden = false, + containerStyle; + if (this.paper.canvas.parentElement) { + containerStyle = this.paper.canvas.parentElement.style; + } //IE10+ can't find parentElement + else if (this.paper.canvas.parentNode) { + containerStyle = this.paper.canvas.parentNode.style; + } + + if(containerStyle && containerStyle.display == "none") { + canvasHidden = true; + containerStyle.display = ""; + } + var bbox = {}; + try { + bbox = this.node.getBBox(); + } catch(e) { + // Firefox 3.0.x, 25.0.1 (probably more versions affected) play badly here - possible fix + bbox = { + x: this.node.clientLeft, + y: this.node.clientTop, + width: this.node.clientWidth, + height: this.node.clientHeight + } + } finally { + bbox = bbox || {}; + if(canvasHidden){ + containerStyle.display = "none"; + } + } + hide && this.hide(); + return bbox; + }; + /*\ + * Element.attr + [ method ] + ** + * Sets the attributes of the element. + > Parameters + - attrName (string) attribute’s name + - value (string) value + * or + - params (object) object of name/value pairs + * or + - attrName (string) attribute’s name + * or + - attrNames (array) in this case method returns array of current values for given attribute names + = (object) @Element if attrsName & value or params are passed in. + = (...) value of the attribute if only attrsName is passed in. + = (array) array of values of the attribute if attrsNames is passed in. + = (object) object of attributes if nothing is passed in. + > Possible parameters + # <p>Please refer to the <a href="http://www.w3.org/TR/SVG/" title="The W3C Recommendation for the SVG language describes these properties in detail.">SVG specification</a> for an explanation of these parameters.</p> + o arrow-end (string) arrowhead on the end of the path. The format for string is `<type>[-<width>[-<length>]]`. Possible types: `classic`, `block`, `open`, `oval`, `diamond`, `none`, width: `wide`, `narrow`, `medium`, length: `long`, `short`, `midium`. + o clip-rect (string) comma or space separated values: x, y, width and height + o cursor (string) CSS type of the cursor + o cx (number) the x-axis coordinate of the center of the circle, or ellipse + o cy (number) the y-axis coordinate of the center of the circle, or ellipse + o fill (string) colour, gradient or image + o fill-opacity (number) + o font (string) + o font-family (string) + o font-size (number) font size in pixels + o font-weight (string) + o height (number) + o href (string) URL, if specified element behaves as hyperlink + o opacity (number) + o path (string) SVG path string format + o r (number) radius of the circle, ellipse or rounded corner on the rect + o rx (number) horisontal radius of the ellipse + o ry (number) vertical radius of the ellipse + o src (string) image URL, only works for @Element.image element + o stroke (string) stroke colour + o stroke-dasharray (string) [“”, “`-`”, “`.`”, “`-.`”, “`-..`”, “`. `”, “`- `”, “`--`”, “`- .`”, “`--.`”, “`--..`”] + o stroke-linecap (string) [“`butt`”, “`square`”, “`round`”] + o stroke-linejoin (string) [“`bevel`”, “`round`”, “`miter`”] + o stroke-miterlimit (number) + o stroke-opacity (number) + o stroke-width (number) stroke width in pixels, default is '1' + o target (string) used with href + o text (string) contents of the text element. Use `\n` for multiline text + o text-anchor (string) [“`start`”, “`middle`”, “`end`”], default is “`middle`” + o title (string) will create tooltip with a given text + o transform (string) see @Element.transform + o width (number) + o x (number) + o y (number) + > Gradients + * Linear gradient format: “`‹angle›-‹colour›[-‹colour›[:‹offset›]]*-‹colour›`”, example: “`90-#fff-#000`” – 90° + * gradient from white to black or “`0-#fff-#f00:20-#000`” – 0° gradient from white via red (at 20%) to black. + * + * radial gradient: “`r[(‹fx›, ‹fy›)]‹colour›[-‹colour›[:‹offset›]]*-‹colour›`”, example: “`r#fff-#000`” – + * gradient from white to black or “`r(0.25, 0.75)#fff-#000`” – gradient from white to black with focus point + * at 0.25, 0.75. Focus point coordinates are in 0..1 range. Radial gradients can only be applied to circles and ellipses. + > Path String + # <p>Please refer to <a href="http://www.w3.org/TR/SVG/paths.html#PathData" title="Details of a path’s data attribute’s format are described in the SVG specification.">SVG documentation regarding path string</a>. Raphaël fully supports it.</p> + > Colour Parsing + # <ul> + # <li>Colour name (“<code>red</code>”, “<code>green</code>”, “<code>cornflowerblue</code>”, etc)</li> + # <li>#••• — shortened HTML colour: (“<code>#000</code>”, “<code>#fc0</code>”, etc)</li> + # <li>#•••••• — full length HTML colour: (“<code>#000000</code>”, “<code>#bd2300</code>”)</li> + # <li>rgb(•••, •••, •••) — red, green and blue channels’ values: (“<code>rgb(200, 100, 0)</code>”)</li> + # <li>rgb(•••%, •••%, •••%) — same as above, but in %: (“<code>rgb(100%, 175%, 0%)</code>”)</li> + # <li>rgba(•••, •••, •••, •••) — red, green and blue channels’ values: (“<code>rgba(200, 100, 0, .5)</code>”)</li> + # <li>rgba(•••%, •••%, •••%, •••%) — same as above, but in %: (“<code>rgba(100%, 175%, 0%, 50%)</code>”)</li> + # <li>hsb(•••, •••, •••) — hue, saturation and brightness values: (“<code>hsb(0.5, 0.25, 1)</code>”)</li> + # <li>hsb(•••%, •••%, •••%) — same as above, but in %</li> + # <li>hsba(•••, •••, •••, •••) — same as above, but with opacity</li> + # <li>hsl(•••, •••, •••) — almost the same as hsb, see <a href="http://en.wikipedia.org/wiki/HSL_and_HSV" title="HSL and HSV - Wikipedia, the free encyclopedia">Wikipedia page</a></li> + # <li>hsl(•••%, •••%, •••%) — same as above, but in %</li> + # <li>hsla(•••, •••, •••, •••) — same as above, but with opacity</li> + # <li>Optionally for hsb and hsl you could specify hue as a degree: “<code>hsl(240deg, 1, .5)</code>” or, if you want to go fancy, “<code>hsl(240°, 1, .5)</code>”</li> + # </ul> + \*/ + elproto.attr = function (name, value) { + if (this.removed) { + return this; + } + if (name == null) { + var res = {}; + for (var a in this.attrs) if (this.attrs[has](a)) { + res[a] = this.attrs[a]; + } + res.gradient && res.fill == "none" && (res.fill = res.gradient) && delete res.gradient; + res.transform = this._.transform; + return res; + } + if (value == null && R.is(name, "string")) { + if (name == "fill" && this.attrs.fill == "none" && this.attrs.gradient) { + return this.attrs.gradient; + } + if (name == "transform") { + return this._.transform; + } + var names = name.split(separator), + out = {}; + for (var i = 0, ii = names.length; i < ii; i++) { + name = names[i]; + if (name in this.attrs) { + out[name] = this.attrs[name]; + } else if (R.is(this.paper.customAttributes[name], "function")) { + out[name] = this.paper.customAttributes[name].def; + } else { + out[name] = R._availableAttrs[name]; + } + } + return ii - 1 ? out : out[names[0]]; + } + if (value == null && R.is(name, "array")) { + out = {}; + for (i = 0, ii = name.length; i < ii; i++) { + out[name[i]] = this.attr(name[i]); + } + return out; + } + if (value != null) { + var params = {}; + params[name] = value; + } else if (name != null && R.is(name, "object")) { + params = name; + } + for (var key in params) { + eve("raphael.attr." + key + "." + this.id, this, params[key]); + } + for (key in this.paper.customAttributes) if (this.paper.customAttributes[has](key) && params[has](key) && R.is(this.paper.customAttributes[key], "function")) { + var par = this.paper.customAttributes[key].apply(this, [].concat(params[key])); + this.attrs[key] = params[key]; + for (var subkey in par) if (par[has](subkey)) { + params[subkey] = par[subkey]; + } + } + setFillAndStroke(this, params); + return this; + }; + /*\ + * Element.toFront + [ method ] + ** + * Moves the element so it is the closest to the viewer’s eyes, on top of other elements. + = (object) @Element + \*/ + elproto.toFront = function () { + if (this.removed) { + return this; + } + var node = getRealNode(this.node); + node.parentNode.appendChild(node); + var svg = this.paper; + svg.top != this && R._tofront(this, svg); + return this; + }; + /*\ + * Element.toBack + [ method ] + ** + * Moves the element so it is the furthest from the viewer’s eyes, behind other elements. + = (object) @Element + \*/ + elproto.toBack = function () { + if (this.removed) { + return this; + } + var node = getRealNode(this.node); + var parentNode = node.parentNode; + parentNode.insertBefore(node, parentNode.firstChild); + R._toback(this, this.paper); + var svg = this.paper; + return this; + }; + /*\ + * Element.insertAfter + [ method ] + ** + * Inserts current object after the given one. + = (object) @Element + \*/ + elproto.insertAfter = function (element) { + if (this.removed || !element) { + return this; + } + + var node = getRealNode(this.node); + var afterNode = getRealNode(element.node || element[element.length - 1].node); + if (afterNode.nextSibling) { + afterNode.parentNode.insertBefore(node, afterNode.nextSibling); + } else { + afterNode.parentNode.appendChild(node); + } + R._insertafter(this, element, this.paper); + return this; + }; + /*\ + * Element.insertBefore + [ method ] + ** + * Inserts current object before the given one. + = (object) @Element + \*/ + elproto.insertBefore = function (element) { + if (this.removed || !element) { + return this; + } + + var node = getRealNode(this.node); + var beforeNode = getRealNode(element.node || element[0].node); + beforeNode.parentNode.insertBefore(node, beforeNode); + R._insertbefore(this, element, this.paper); + return this; + }; + elproto.blur = function (size) { + // Experimental. No Safari support. Use it on your own risk. + var t = this; + if (+size !== 0) { + var fltr = $("filter"), + blur = $("feGaussianBlur"); + t.attrs.blur = size; + fltr.id = R.createUUID(); + $(blur, {stdDeviation: +size || 1.5}); + fltr.appendChild(blur); + t.paper.defs.appendChild(fltr); + t._blur = fltr; + $(t.node, {filter: "url(#" + fltr.id + ")"}); + } else { + if (t._blur) { + t._blur.parentNode.removeChild(t._blur); + delete t._blur; + delete t.attrs.blur; + } + t.node.removeAttribute("filter"); + } + return t; + }; + R._engine.circle = function (svg, x, y, r) { + var el = $("circle"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {cx: x, cy: y, r: r, fill: "none", stroke: "#000"}; + res.type = "circle"; + $(el, res.attrs); + return res; + }; + R._engine.rect = function (svg, x, y, w, h, r) { + var el = $("rect"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {x: x, y: y, width: w, height: h, rx: r || 0, ry: r || 0, fill: "none", stroke: "#000"}; + res.type = "rect"; + $(el, res.attrs); + return res; + }; + R._engine.ellipse = function (svg, x, y, rx, ry) { + var el = $("ellipse"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {cx: x, cy: y, rx: rx, ry: ry, fill: "none", stroke: "#000"}; + res.type = "ellipse"; + $(el, res.attrs); + return res; + }; + R._engine.image = function (svg, src, x, y, w, h) { + var el = $("image"); + $(el, {x: x, y: y, width: w, height: h, preserveAspectRatio: "none"}); + el.setAttributeNS(xlink, "href", src); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {x: x, y: y, width: w, height: h, src: src}; + res.type = "image"; + return res; + }; + R._engine.text = function (svg, x, y, text) { + var el = $("text"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = { + x: x, + y: y, + "text-anchor": "middle", + text: text, + "font-family": R._availableAttrs["font-family"], + "font-size": R._availableAttrs["font-size"], + stroke: "none", + fill: "#000" + }; + res.type = "text"; + setFillAndStroke(res, res.attrs); + return res; + }; + R._engine.setSize = function (width, height) { + this.width = width || this.width; + this.height = height || this.height; + this.canvas.setAttribute("width", this.width); + this.canvas.setAttribute("height", this.height); + if (this._viewBox) { + this.setViewBox.apply(this, this._viewBox); + } + return this; + }; + R._engine.create = function () { + var con = R._getContainer.apply(0, arguments), + container = con && con.container, + x = con.x, + y = con.y, + width = con.width, + height = con.height; + if (!container) { + throw new Error("SVG container not found."); + } + var cnvs = $("svg"), + css = "overflow:hidden;", + isFloating; + x = x || 0; + y = y || 0; + width = width || 512; + height = height || 342; + $(cnvs, { + height: height, + version: 1.1, + width: width, + xmlns: "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" + }); + if (container == 1) { + cnvs.style.cssText = css + "position:absolute;left:" + x + "px;top:" + y + "px"; + R._g.doc.body.appendChild(cnvs); + isFloating = 1; + } else { + cnvs.style.cssText = css + "position:relative"; + if (container.firstChild) { + container.insertBefore(cnvs, container.firstChild); + } else { + container.appendChild(cnvs); + } + } + container = new R._Paper; + container.width = width; + container.height = height; + container.canvas = cnvs; + container.clear(); + container._left = container._top = 0; + isFloating && (container.renderfix = function () {}); + container.renderfix(); + return container; + }; + R._engine.setViewBox = function (x, y, w, h, fit) { + eve("raphael.setViewBox", this, this._viewBox, [x, y, w, h, fit]); + var paperSize = this.getSize(), + size = mmax(w / paperSize.width, h / paperSize.height), + top = this.top, + aspectRatio = fit ? "xMidYMid meet" : "xMinYMin", + vb, + sw; + if (x == null) { + if (this._vbSize) { + size = 1; + } + delete this._vbSize; + vb = "0 0 " + this.width + S + this.height; + } else { + this._vbSize = size; + vb = x + S + y + S + w + S + h; + } + $(this.canvas, { + viewBox: vb, + preserveAspectRatio: aspectRatio + }); + while (size && top) { + sw = "stroke-width" in top.attrs ? top.attrs["stroke-width"] : 1; + top.attr({"stroke-width": sw}); + top._.dirty = 1; + top._.dirtyT = 1; + top = top.prev; + } + this._viewBox = [x, y, w, h, !!fit]; + return this; + }; + /*\ + * Paper.renderfix + [ method ] + ** + * Fixes the issue of Firefox and IE9 regarding subpixel rendering. If paper is dependant + * on other elements after reflow it could shift half pixel which cause for lines to lost their crispness. + * This method fixes the issue. + ** + Special thanks to Mariusz Nowak (http://www.medikoo.com/) for this method. + \*/ + R.prototype.renderfix = function () { + var cnvs = this.canvas, + s = cnvs.style, + pos; + try { + pos = cnvs.getScreenCTM() || cnvs.createSVGMatrix(); + } catch (e) { + pos = cnvs.createSVGMatrix(); + } + var left = -pos.e % 1, + top = -pos.f % 1; + if (left || top) { + if (left) { + this._left = (this._left + left) % 1; + s.left = this._left + "px"; + } + if (top) { + this._top = (this._top + top) % 1; + s.top = this._top + "px"; + } + } + }; + /*\ + * Paper.clear + [ method ] + ** + * Clears the paper, i.e. removes all the elements. + \*/ + R.prototype.clear = function () { + R.eve("raphael.clear", this); + var c = this.canvas; + while (c.firstChild) { + c.removeChild(c.firstChild); + } + this.bottom = this.top = null; + (this.desc = $("desc")).appendChild(R._g.doc.createTextNode("Created with Rapha\xebl " + R.version)); + c.appendChild(this.desc); + c.appendChild(this.defs = $("defs")); + }; + /*\ + * Paper.remove + [ method ] + ** + * Removes the paper from the DOM. + \*/ + R.prototype.remove = function () { + eve("raphael.remove", this); + this.canvas.parentNode && this.canvas.parentNode.removeChild(this.canvas); + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + }; + var setproto = R.st; + for (var method in elproto) if (elproto[has](method) && !setproto[has](method)) { + setproto[method] = (function (methodname) { + return function () { + var arg = arguments; + return this.forEach(function (el) { + el[methodname].apply(el, arg); + }); + }; + })(method); + } +})(); + +// ┌─────────────────────────────────────────────────────────────────────┐ \\ +// │ Raphaël - JavaScript Vector Library │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ VML Module │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright (c) 2008-2011 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright (c) 2008-2011 Sencha Labs (http://sencha.com) │ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license. │ \\ +// └─────────────────────────────────────────────────────────────────────┘ \\ + +(function(){ + if (!R.vml) { + return; + } + var has = "hasOwnProperty", + Str = String, + toFloat = parseFloat, + math = Math, + round = math.round, + mmax = math.max, + mmin = math.min, + abs = math.abs, + fillString = "fill", + separator = /[, ]+/, + eve = R.eve, + ms = " progid:DXImageTransform.Microsoft", + S = " ", + E = "", + map = {M: "m", L: "l", C: "c", Z: "x", m: "t", l: "r", c: "v", z: "x"}, + bites = /([clmz]),?([^clmz]*)/gi, + blurregexp = / progid:\S+Blur\([^\)]+\)/g, + val = /-?[^,\s-]+/g, + cssDot = "position:absolute;left:0;top:0;width:1px;height:1px;behavior:url(#default#VML)", + zoom = 21600, + pathTypes = {path: 1, rect: 1, image: 1}, + ovalTypes = {circle: 1, ellipse: 1}, + path2vml = function (path) { + var total = /[ahqstv]/ig, + command = R._pathToAbsolute; + Str(path).match(total) && (command = R._path2curve); + total = /[clmz]/g; + if (command == R._pathToAbsolute && !Str(path).match(total)) { + var res = Str(path).replace(bites, function (all, command, args) { + var vals = [], + isMove = command.toLowerCase() == "m", + res = map[command]; + args.replace(val, function (value) { + if (isMove && vals.length == 2) { + res += vals + map[command == "m" ? "l" : "L"]; + vals = []; + } + vals.push(round(value * zoom)); + }); + return res + vals; + }); + return res; + } + var pa = command(path), p, r; + res = []; + for (var i = 0, ii = pa.length; i < ii; i++) { + p = pa[i]; + r = pa[i][0].toLowerCase(); + r == "z" && (r = "x"); + for (var j = 1, jj = p.length; j < jj; j++) { + r += round(p[j] * zoom) + (j != jj - 1 ? "," : E); + } + res.push(r); + } + return res.join(S); + }, + compensation = function (deg, dx, dy) { + var m = R.matrix(); + m.rotate(-deg, .5, .5); + return { + dx: m.x(dx, dy), + dy: m.y(dx, dy) + }; + }, + setCoords = function (p, sx, sy, dx, dy, deg) { + var _ = p._, + m = p.matrix, + fillpos = _.fillpos, + o = p.node, + s = o.style, + y = 1, + flip = "", + dxdy, + kx = zoom / sx, + ky = zoom / sy; + s.visibility = "hidden"; + if (!sx || !sy) { + return; + } + o.coordsize = abs(kx) + S + abs(ky); + s.rotation = deg * (sx * sy < 0 ? -1 : 1); + if (deg) { + var c = compensation(deg, dx, dy); + dx = c.dx; + dy = c.dy; + } + sx < 0 && (flip += "x"); + sy < 0 && (flip += " y") && (y = -1); + s.flip = flip; + o.coordorigin = (dx * -kx) + S + (dy * -ky); + if (fillpos || _.fillsize) { + var fill = o.getElementsByTagName(fillString); + fill = fill && fill[0]; + o.removeChild(fill); + if (fillpos) { + c = compensation(deg, m.x(fillpos[0], fillpos[1]), m.y(fillpos[0], fillpos[1])); + fill.position = c.dx * y + S + c.dy * y; + } + if (_.fillsize) { + fill.size = _.fillsize[0] * abs(sx) + S + _.fillsize[1] * abs(sy); + } + o.appendChild(fill); + } + s.visibility = "visible"; + }; + R.toString = function () { + return "Your browser doesn\u2019t support SVG. Falling down to VML.\nYou are running Rapha\xebl " + this.version; + }; + var addArrow = function (o, value, isEnd) { + var values = Str(value).toLowerCase().split("-"), + se = isEnd ? "end" : "start", + i = values.length, + type = "classic", + w = "medium", + h = "medium"; + while (i--) { + switch (values[i]) { + case "block": + case "classic": + case "oval": + case "diamond": + case "open": + case "none": + type = values[i]; + break; + case "wide": + case "narrow": h = values[i]; break; + case "long": + case "short": w = values[i]; break; + } + } + var stroke = o.node.getElementsByTagName("stroke")[0]; + stroke[se + "arrow"] = type; + stroke[se + "arrowlength"] = w; + stroke[se + "arrowwidth"] = h; + }, + setFillAndStroke = function (o, params) { + // o.paper.canvas.style.display = "none"; + o.attrs = o.attrs || {}; + var node = o.node, + a = o.attrs, + s = node.style, + xy, + newpath = pathTypes[o.type] && (params.x != a.x || params.y != a.y || params.width != a.width || params.height != a.height || params.cx != a.cx || params.cy != a.cy || params.rx != a.rx || params.ry != a.ry || params.r != a.r), + isOval = ovalTypes[o.type] && (a.cx != params.cx || a.cy != params.cy || a.r != params.r || a.rx != params.rx || a.ry != params.ry), + res = o; + + + for (var par in params) if (params[has](par)) { + a[par] = params[par]; + } + if (newpath) { + a.path = R._getPath[o.type](o); + o._.dirty = 1; + } + params.href && (node.href = params.href); + params.title && (node.title = params.title); + params.target && (node.target = params.target); + params.cursor && (s.cursor = params.cursor); + "blur" in params && o.blur(params.blur); + if (params.path && o.type == "path" || newpath) { + node.path = path2vml(~Str(a.path).toLowerCase().indexOf("r") ? R._pathToAbsolute(a.path) : a.path); + o._.dirty = 1; + if (o.type == "image") { + o._.fillpos = [a.x, a.y]; + o._.fillsize = [a.width, a.height]; + setCoords(o, 1, 1, 0, 0, 0); + } + } + "transform" in params && o.transform(params.transform); + if (isOval) { + var cx = +a.cx, + cy = +a.cy, + rx = +a.rx || +a.r || 0, + ry = +a.ry || +a.r || 0; + node.path = R.format("ar{0},{1},{2},{3},{4},{1},{4},{1}x", round((cx - rx) * zoom), round((cy - ry) * zoom), round((cx + rx) * zoom), round((cy + ry) * zoom), round(cx * zoom)); + o._.dirty = 1; + } + if ("clip-rect" in params) { + var rect = Str(params["clip-rect"]).split(separator); + if (rect.length == 4) { + rect[2] = +rect[2] + (+rect[0]); + rect[3] = +rect[3] + (+rect[1]); + var div = node.clipRect || R._g.doc.createElement("div"), + dstyle = div.style; + dstyle.clip = R.format("rect({1}px {2}px {3}px {0}px)", rect); + if (!node.clipRect) { + dstyle.position = "absolute"; + dstyle.top = 0; + dstyle.left = 0; + dstyle.width = o.paper.width + "px"; + dstyle.height = o.paper.height + "px"; + node.parentNode.insertBefore(div, node); + div.appendChild(node); + node.clipRect = div; + } + } + if (!params["clip-rect"]) { + node.clipRect && (node.clipRect.style.clip = "auto"); + } + } + if (o.textpath) { + var textpathStyle = o.textpath.style; + params.font && (textpathStyle.font = params.font); + params["font-family"] && (textpathStyle.fontFamily = '"' + params["font-family"].split(",")[0].replace(/^['"]+|['"]+$/g, E) + '"'); + params["font-size"] && (textpathStyle.fontSize = params["font-size"]); + params["font-weight"] && (textpathStyle.fontWeight = params["font-weight"]); + params["font-style"] && (textpathStyle.fontStyle = params["font-style"]); + } + if ("arrow-start" in params) { + addArrow(res, params["arrow-start"]); + } + if ("arrow-end" in params) { + addArrow(res, params["arrow-end"], 1); + } + if (params.opacity != null || + params["stroke-width"] != null || + params.fill != null || + params.src != null || + params.stroke != null || + params["stroke-width"] != null || + params["stroke-opacity"] != null || + params["fill-opacity"] != null || + params["stroke-dasharray"] != null || + params["stroke-miterlimit"] != null || + params["stroke-linejoin"] != null || + params["stroke-linecap"] != null) { + var fill = node.getElementsByTagName(fillString), + newfill = false; + fill = fill && fill[0]; + !fill && (newfill = fill = createNode(fillString)); + if (o.type == "image" && params.src) { + fill.src = params.src; + } + params.fill && (fill.on = true); + if (fill.on == null || params.fill == "none" || params.fill === null) { + fill.on = false; + } + if (fill.on && params.fill) { + var isURL = Str(params.fill).match(R._ISURL); + if (isURL) { + fill.parentNode == node && node.removeChild(fill); + fill.rotate = true; + fill.src = isURL[1]; + fill.type = "tile"; + var bbox = o.getBBox(1); + fill.position = bbox.x + S + bbox.y; + o._.fillpos = [bbox.x, bbox.y]; + + R._preload(isURL[1], function () { + o._.fillsize = [this.offsetWidth, this.offsetHeight]; + }); + } else { + fill.color = R.getRGB(params.fill).hex; + fill.src = E; + fill.type = "solid"; + if (R.getRGB(params.fill).error && (res.type in {circle: 1, ellipse: 1} || Str(params.fill).charAt() != "r") && addGradientFill(res, params.fill, fill)) { + a.fill = "none"; + a.gradient = params.fill; + fill.rotate = false; + } + } + } + if ("fill-opacity" in params || "opacity" in params) { + var opacity = ((+a["fill-opacity"] + 1 || 2) - 1) * ((+a.opacity + 1 || 2) - 1) * ((+R.getRGB(params.fill).o + 1 || 2) - 1); + opacity = mmin(mmax(opacity, 0), 1); + fill.opacity = opacity; + if (fill.src) { + fill.color = "none"; + } + } + node.appendChild(fill); + var stroke = (node.getElementsByTagName("stroke") && node.getElementsByTagName("stroke")[0]), + newstroke = false; + !stroke && (newstroke = stroke = createNode("stroke")); + if ((params.stroke && params.stroke != "none") || + params["stroke-width"] || + params["stroke-opacity"] != null || + params["stroke-dasharray"] || + params["stroke-miterlimit"] || + params["stroke-linejoin"] || + params["stroke-linecap"]) { + stroke.on = true; + } + (params.stroke == "none" || params.stroke === null || stroke.on == null || params.stroke == 0 || params["stroke-width"] == 0) && (stroke.on = false); + var strokeColor = R.getRGB(params.stroke); + stroke.on && params.stroke && (stroke.color = strokeColor.hex); + opacity = ((+a["stroke-opacity"] + 1 || 2) - 1) * ((+a.opacity + 1 || 2) - 1) * ((+strokeColor.o + 1 || 2) - 1); + var width = (toFloat(params["stroke-width"]) || 1) * .75; + opacity = mmin(mmax(opacity, 0), 1); + params["stroke-width"] == null && (width = a["stroke-width"]); + params["stroke-width"] && (stroke.weight = width); + width && width < 1 && (opacity *= width) && (stroke.weight = 1); + stroke.opacity = opacity; + + params["stroke-linejoin"] && (stroke.joinstyle = params["stroke-linejoin"] || "miter"); + stroke.miterlimit = params["stroke-miterlimit"] || 8; + params["stroke-linecap"] && (stroke.endcap = params["stroke-linecap"] == "butt" ? "flat" : params["stroke-linecap"] == "square" ? "square" : "round"); + if ("stroke-dasharray" in params) { + var dasharray = { + "-": "shortdash", + ".": "shortdot", + "-.": "shortdashdot", + "-..": "shortdashdotdot", + ". ": "dot", + "- ": "dash", + "--": "longdash", + "- .": "dashdot", + "--.": "longdashdot", + "--..": "longdashdotdot" + }; + stroke.dashstyle = dasharray[has](params["stroke-dasharray"]) ? dasharray[params["stroke-dasharray"]] : E; + } + newstroke && node.appendChild(stroke); + } + if (res.type == "text") { + res.paper.canvas.style.display = E; + var span = res.paper.span, + m = 100, + fontSize = a.font && a.font.match(/\d+(?:\.\d*)?(?=px)/); + s = span.style; + a.font && (s.font = a.font); + a["font-family"] && (s.fontFamily = a["font-family"]); + a["font-weight"] && (s.fontWeight = a["font-weight"]); + a["font-style"] && (s.fontStyle = a["font-style"]); + fontSize = toFloat(a["font-size"] || fontSize && fontSize[0]) || 10; + s.fontSize = fontSize * m + "px"; + res.textpath.string && (span.innerHTML = Str(res.textpath.string).replace(/</g, "<").replace(/&/g, "&").replace(/\n/g, "<br>")); + var brect = span.getBoundingClientRect(); + res.W = a.w = (brect.right - brect.left) / m; + res.H = a.h = (brect.bottom - brect.top) / m; + // res.paper.canvas.style.display = "none"; + res.X = a.x; + res.Y = a.y + res.H / 2; + + ("x" in params || "y" in params) && (res.path.v = R.format("m{0},{1}l{2},{1}", round(a.x * zoom), round(a.y * zoom), round(a.x * zoom) + 1)); + var dirtyattrs = ["x", "y", "text", "font", "font-family", "font-weight", "font-style", "font-size"]; + for (var d = 0, dd = dirtyattrs.length; d < dd; d++) if (dirtyattrs[d] in params) { + res._.dirty = 1; + break; + } + + // text-anchor emulation + switch (a["text-anchor"]) { + case "start": + res.textpath.style["v-text-align"] = "left"; + res.bbx = res.W / 2; + break; + case "end": + res.textpath.style["v-text-align"] = "right"; + res.bbx = -res.W / 2; + break; + default: + res.textpath.style["v-text-align"] = "center"; + res.bbx = 0; + break; + } + res.textpath.style["v-text-kern"] = true; + } + // res.paper.canvas.style.display = E; + }, + addGradientFill = function (o, gradient, fill) { + o.attrs = o.attrs || {}; + var attrs = o.attrs, + pow = Math.pow, + opacity, + oindex, + type = "linear", + fxfy = ".5 .5"; + o.attrs.gradient = gradient; + gradient = Str(gradient).replace(R._radial_gradient, function (all, fx, fy) { + type = "radial"; + if (fx && fy) { + fx = toFloat(fx); + fy = toFloat(fy); + pow(fx - .5, 2) + pow(fy - .5, 2) > .25 && (fy = math.sqrt(.25 - pow(fx - .5, 2)) * ((fy > .5) * 2 - 1) + .5); + fxfy = fx + S + fy; + } + return E; + }); + gradient = gradient.split(/\s*\-\s*/); + if (type == "linear") { + var angle = gradient.shift(); + angle = -toFloat(angle); + if (isNaN(angle)) { + return null; + } + } + var dots = R._parseDots(gradient); + if (!dots) { + return null; + } + o = o.shape || o.node; + if (dots.length) { + o.removeChild(fill); + fill.on = true; + fill.method = "none"; + fill.color = dots[0].color; + fill.color2 = dots[dots.length - 1].color; + var clrs = []; + for (var i = 0, ii = dots.length; i < ii; i++) { + dots[i].offset && clrs.push(dots[i].offset + S + dots[i].color); + } + fill.colors = clrs.length ? clrs.join() : "0% " + fill.color; + if (type == "radial") { + fill.type = "gradientTitle"; + fill.focus = "100%"; + fill.focussize = "0 0"; + fill.focusposition = fxfy; + fill.angle = 0; + } else { + // fill.rotate= true; + fill.type = "gradient"; + fill.angle = (270 - angle) % 360; + } + o.appendChild(fill); + } + return 1; + }, + Element = function (node, vml) { + this[0] = this.node = node; + node.raphael = true; + this.id = R._oid++; + node.raphaelid = this.id; + this.X = 0; + this.Y = 0; + this.attrs = {}; + this.paper = vml; + this.matrix = R.matrix(); + this._ = { + transform: [], + sx: 1, + sy: 1, + dx: 0, + dy: 0, + deg: 0, + dirty: 1, + dirtyT: 1 + }; + !vml.bottom && (vml.bottom = this); + this.prev = vml.top; + vml.top && (vml.top.next = this); + vml.top = this; + this.next = null; + }; + var elproto = R.el; + + Element.prototype = elproto; + elproto.constructor = Element; + elproto.transform = function (tstr) { + if (tstr == null) { + return this._.transform; + } + var vbs = this.paper._viewBoxShift, + vbt = vbs ? "s" + [vbs.scale, vbs.scale] + "-1-1t" + [vbs.dx, vbs.dy] : E, + oldt; + if (vbs) { + oldt = tstr = Str(tstr).replace(/\.{3}|\u2026/g, this._.transform || E); + } + R._extractTransform(this, vbt + tstr); + var matrix = this.matrix.clone(), + skew = this.skew, + o = this.node, + split, + isGrad = ~Str(this.attrs.fill).indexOf("-"), + isPatt = !Str(this.attrs.fill).indexOf("url("); + matrix.translate(1, 1); + if (isPatt || isGrad || this.type == "image") { + skew.matrix = "1 0 0 1"; + skew.offset = "0 0"; + split = matrix.split(); + if ((isGrad && split.noRotation) || !split.isSimple) { + o.style.filter = matrix.toFilter(); + var bb = this.getBBox(), + bbt = this.getBBox(1), + dx = bb.x - bbt.x, + dy = bb.y - bbt.y; + o.coordorigin = (dx * -zoom) + S + (dy * -zoom); + setCoords(this, 1, 1, dx, dy, 0); + } else { + o.style.filter = E; + setCoords(this, split.scalex, split.scaley, split.dx, split.dy, split.rotate); + } + } else { + o.style.filter = E; + skew.matrix = Str(matrix); + skew.offset = matrix.offset(); + } + if (oldt !== null) { // empty string value is true as well + this._.transform = oldt; + R._extractTransform(this, oldt); + } + return this; + }; + elproto.rotate = function (deg, cx, cy) { + if (this.removed) { + return this; + } + if (deg == null) { + return; + } + deg = Str(deg).split(separator); + if (deg.length - 1) { + cx = toFloat(deg[1]); + cy = toFloat(deg[2]); + } + deg = toFloat(deg[0]); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + cx = bbox.x + bbox.width / 2; + cy = bbox.y + bbox.height / 2; + } + this._.dirtyT = 1; + this.transform(this._.transform.concat([["r", deg, cx, cy]])); + return this; + }; + elproto.translate = function (dx, dy) { + if (this.removed) { + return this; + } + dx = Str(dx).split(separator); + if (dx.length - 1) { + dy = toFloat(dx[1]); + } + dx = toFloat(dx[0]) || 0; + dy = +dy || 0; + if (this._.bbox) { + this._.bbox.x += dx; + this._.bbox.y += dy; + } + this.transform(this._.transform.concat([["t", dx, dy]])); + return this; + }; + elproto.scale = function (sx, sy, cx, cy) { + if (this.removed) { + return this; + } + sx = Str(sx).split(separator); + if (sx.length - 1) { + sy = toFloat(sx[1]); + cx = toFloat(sx[2]); + cy = toFloat(sx[3]); + isNaN(cx) && (cx = null); + isNaN(cy) && (cy = null); + } + sx = toFloat(sx[0]); + (sy == null) && (sy = sx); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + } + cx = cx == null ? bbox.x + bbox.width / 2 : cx; + cy = cy == null ? bbox.y + bbox.height / 2 : cy; + + this.transform(this._.transform.concat([["s", sx, sy, cx, cy]])); + this._.dirtyT = 1; + return this; + }; + elproto.hide = function () { + !this.removed && (this.node.style.display = "none"); + return this; + }; + elproto.show = function () { + !this.removed && (this.node.style.display = E); + return this; + }; + // Needed to fix the vml setViewBox issues + elproto.auxGetBBox = R.el.getBBox; + elproto.getBBox = function(){ + var b = this.auxGetBBox(); + if (this.paper && this.paper._viewBoxShift) + { + var c = {}; + var z = 1/this.paper._viewBoxShift.scale; + c.x = b.x - this.paper._viewBoxShift.dx; + c.x *= z; + c.y = b.y - this.paper._viewBoxShift.dy; + c.y *= z; + c.width = b.width * z; + c.height = b.height * z; + c.x2 = c.x + c.width; + c.y2 = c.y + c.height; + return c; + } + return b; + }; + elproto._getBBox = function () { + if (this.removed) { + return {}; + } + return { + x: this.X + (this.bbx || 0) - this.W / 2, + y: this.Y - this.H, + width: this.W, + height: this.H + }; + }; + elproto.remove = function () { + if (this.removed || !this.node.parentNode) { + return; + } + this.paper.__set__ && this.paper.__set__.exclude(this); + R.eve.unbind("raphael.*.*." + this.id); + R._tear(this, this.paper); + this.node.parentNode.removeChild(this.node); + this.shape && this.shape.parentNode.removeChild(this.shape); + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + this.removed = true; + }; + elproto.attr = function (name, value) { + if (this.removed) { + return this; + } + if (name == null) { + var res = {}; + for (var a in this.attrs) if (this.attrs[has](a)) { + res[a] = this.attrs[a]; + } + res.gradient && res.fill == "none" && (res.fill = res.gradient) && delete res.gradient; + res.transform = this._.transform; + return res; + } + if (value == null && R.is(name, "string")) { + if (name == fillString && this.attrs.fill == "none" && this.attrs.gradient) { + return this.attrs.gradient; + } + var names = name.split(separator), + out = {}; + for (var i = 0, ii = names.length; i < ii; i++) { + name = names[i]; + if (name in this.attrs) { + out[name] = this.attrs[name]; + } else if (R.is(this.paper.customAttributes[name], "function")) { + out[name] = this.paper.customAttributes[name].def; + } else { + out[name] = R._availableAttrs[name]; + } + } + return ii - 1 ? out : out[names[0]]; + } + if (this.attrs && value == null && R.is(name, "array")) { + out = {}; + for (i = 0, ii = name.length; i < ii; i++) { + out[name[i]] = this.attr(name[i]); + } + return out; + } + var params; + if (value != null) { + params = {}; + params[name] = value; + } + value == null && R.is(name, "object") && (params = name); + for (var key in params) { + eve("raphael.attr." + key + "." + this.id, this, params[key]); + } + if (params) { + for (key in this.paper.customAttributes) if (this.paper.customAttributes[has](key) && params[has](key) && R.is(this.paper.customAttributes[key], "function")) { + var par = this.paper.customAttributes[key].apply(this, [].concat(params[key])); + this.attrs[key] = params[key]; + for (var subkey in par) if (par[has](subkey)) { + params[subkey] = par[subkey]; + } + } + // this.paper.canvas.style.display = "none"; + if (params.text && this.type == "text") { + this.textpath.string = params.text; + } + setFillAndStroke(this, params); + // this.paper.canvas.style.display = E; + } + return this; + }; + elproto.toFront = function () { + !this.removed && this.node.parentNode.appendChild(this.node); + this.paper && this.paper.top != this && R._tofront(this, this.paper); + return this; + }; + elproto.toBack = function () { + if (this.removed) { + return this; + } + if (this.node.parentNode.firstChild != this.node) { + this.node.parentNode.insertBefore(this.node, this.node.parentNode.firstChild); + R._toback(this, this.paper); + } + return this; + }; + elproto.insertAfter = function (element) { + if (this.removed) { + return this; + } + if (element.constructor == R.st.constructor) { + element = element[element.length - 1]; + } + if (element.node.nextSibling) { + element.node.parentNode.insertBefore(this.node, element.node.nextSibling); + } else { + element.node.parentNode.appendChild(this.node); + } + R._insertafter(this, element, this.paper); + return this; + }; + elproto.insertBefore = function (element) { + if (this.removed) { + return this; + } + if (element.constructor == R.st.constructor) { + element = element[0]; + } + element.node.parentNode.insertBefore(this.node, element.node); + R._insertbefore(this, element, this.paper); + return this; + }; + elproto.blur = function (size) { + var s = this.node.runtimeStyle, + f = s.filter; + f = f.replace(blurregexp, E); + if (+size !== 0) { + this.attrs.blur = size; + s.filter = f + S + ms + ".Blur(pixelradius=" + (+size || 1.5) + ")"; + s.margin = R.format("-{0}px 0 0 -{0}px", round(+size || 1.5)); + } else { + s.filter = f; + s.margin = 0; + delete this.attrs.blur; + } + return this; + }; + + R._engine.path = function (pathString, vml) { + var el = createNode("shape"); + el.style.cssText = cssDot; + el.coordsize = zoom + S + zoom; + el.coordorigin = vml.coordorigin; + var p = new Element(el, vml), + attr = {fill: "none", stroke: "#000"}; + pathString && (attr.path = pathString); + p.type = "path"; + p.path = []; + p.Path = E; + setFillAndStroke(p, attr); + vml.canvas.appendChild(el); + var skew = createNode("skew"); + skew.on = true; + el.appendChild(skew); + p.skew = skew; + p.transform(E); + return p; + }; + R._engine.rect = function (vml, x, y, w, h, r) { + var path = R._rectPath(x, y, w, h, r), + res = vml.path(path), + a = res.attrs; + res.X = a.x = x; + res.Y = a.y = y; + res.W = a.width = w; + res.H = a.height = h; + a.r = r; + a.path = path; + res.type = "rect"; + return res; + }; + R._engine.ellipse = function (vml, x, y, rx, ry) { + var res = vml.path(), + a = res.attrs; + res.X = x - rx; + res.Y = y - ry; + res.W = rx * 2; + res.H = ry * 2; + res.type = "ellipse"; + setFillAndStroke(res, { + cx: x, + cy: y, + rx: rx, + ry: ry + }); + return res; + }; + R._engine.circle = function (vml, x, y, r) { + var res = vml.path(), + a = res.attrs; + res.X = x - r; + res.Y = y - r; + res.W = res.H = r * 2; + res.type = "circle"; + setFillAndStroke(res, { + cx: x, + cy: y, + r: r + }); + return res; + }; + R._engine.image = function (vml, src, x, y, w, h) { + var path = R._rectPath(x, y, w, h), + res = vml.path(path).attr({stroke: "none"}), + a = res.attrs, + node = res.node, + fill = node.getElementsByTagName(fillString)[0]; + a.src = src; + res.X = a.x = x; + res.Y = a.y = y; + res.W = a.width = w; + res.H = a.height = h; + a.path = path; + res.type = "image"; + fill.parentNode == node && node.removeChild(fill); + fill.rotate = true; + fill.src = src; + fill.type = "tile"; + res._.fillpos = [x, y]; + res._.fillsize = [w, h]; + node.appendChild(fill); + setCoords(res, 1, 1, 0, 0, 0); + return res; + }; + R._engine.text = function (vml, x, y, text) { + var el = createNode("shape"), + path = createNode("path"), + o = createNode("textpath"); + x = x || 0; + y = y || 0; + text = text || ""; + path.v = R.format("m{0},{1}l{2},{1}", round(x * zoom), round(y * zoom), round(x * zoom) + 1); + path.textpathok = true; + o.string = Str(text); + o.on = true; + el.style.cssText = cssDot; + el.coordsize = zoom + S + zoom; + el.coordorigin = "0 0"; + var p = new Element(el, vml), + attr = { + fill: "#000", + stroke: "none", + font: R._availableAttrs.font, + text: text + }; + p.shape = el; + p.path = path; + p.textpath = o; + p.type = "text"; + p.attrs.text = Str(text); + p.attrs.x = x; + p.attrs.y = y; + p.attrs.w = 1; + p.attrs.h = 1; + setFillAndStroke(p, attr); + el.appendChild(o); + el.appendChild(path); + vml.canvas.appendChild(el); + var skew = createNode("skew"); + skew.on = true; + el.appendChild(skew); + p.skew = skew; + p.transform(E); + return p; + }; + R._engine.setSize = function (width, height) { + var cs = this.canvas.style; + this.width = width; + this.height = height; + width == +width && (width += "px"); + height == +height && (height += "px"); + cs.width = width; + cs.height = height; + cs.clip = "rect(0 " + width + " " + height + " 0)"; + if (this._viewBox) { + R._engine.setViewBox.apply(this, this._viewBox); + } + return this; + }; + R._engine.setViewBox = function (x, y, w, h, fit) { + R.eve("raphael.setViewBox", this, this._viewBox, [x, y, w, h, fit]); + var paperSize = this.getSize(), + width = paperSize.width, + height = paperSize.height, + H, W; + if (fit) { + H = height / h; + W = width / w; + if (w * H < width) { + x -= (width - w * H) / 2 / H; + } + if (h * W < height) { + y -= (height - h * W) / 2 / W; + } + } + this._viewBox = [x, y, w, h, !!fit]; + this._viewBoxShift = { + dx: -x, + dy: -y, + scale: paperSize + }; + this.forEach(function (el) { + el.transform("..."); + }); + return this; + }; + var createNode; + R._engine.initWin = function (win) { + var doc = win.document; + if (doc.styleSheets.length < 31) { + doc.createStyleSheet().addRule(".rvml", "behavior:url(#default#VML)"); + } else { + // no more room, add to the existing one + // http://msdn.microsoft.com/en-us/library/ms531194%28VS.85%29.aspx + doc.styleSheets[0].addRule(".rvml", "behavior:url(#default#VML)"); + } + try { + !doc.namespaces.rvml && doc.namespaces.add("rvml", "urn:schemas-microsoft-com:vml"); + createNode = function (tagName) { + return doc.createElement('<rvml:' + tagName + ' class="rvml">'); + }; + } catch (e) { + createNode = function (tagName) { + return doc.createElement('<' + tagName + ' xmlns="urn:schemas-microsoft.com:vml" class="rvml">'); + }; + } + }; + R._engine.initWin(R._g.win); + R._engine.create = function () { + var con = R._getContainer.apply(0, arguments), + container = con.container, + height = con.height, + s, + width = con.width, + x = con.x, + y = con.y; + if (!container) { + throw new Error("VML container not found."); + } + var res = new R._Paper, + c = res.canvas = R._g.doc.createElement("div"), + cs = c.style; + x = x || 0; + y = y || 0; + width = width || 512; + height = height || 342; + res.width = width; + res.height = height; + width == +width && (width += "px"); + height == +height && (height += "px"); + res.coordsize = zoom * 1e3 + S + zoom * 1e3; + res.coordorigin = "0 0"; + res.span = R._g.doc.createElement("span"); + res.span.style.cssText = "position:absolute;left:-9999em;top:-9999em;padding:0;margin:0;line-height:1;"; + c.appendChild(res.span); + cs.cssText = R.format("top:0;left:0;width:{0};height:{1};display:inline-block;position:relative;clip:rect(0 {0} {1} 0);overflow:hidden", width, height); + if (container == 1) { + R._g.doc.body.appendChild(c); + cs.left = x + "px"; + cs.top = y + "px"; + cs.position = "absolute"; + } else { + if (container.firstChild) { + container.insertBefore(c, container.firstChild); + } else { + container.appendChild(c); + } + } + res.renderfix = function () {}; + return res; + }; + R.prototype.clear = function () { + R.eve("raphael.clear", this); + this.canvas.innerHTML = E; + this.span = R._g.doc.createElement("span"); + this.span.style.cssText = "position:absolute;left:-9999em;top:-9999em;padding:0;margin:0;line-height:1;display:inline;"; + this.canvas.appendChild(this.span); + this.bottom = this.top = null; + }; + R.prototype.remove = function () { + R.eve("raphael.remove", this); + this.canvas.parentNode.removeChild(this.canvas); + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + return true; + }; + + var setproto = R.st; + for (var method in elproto) if (elproto[has](method) && !setproto[has](method)) { + setproto[method] = (function (methodname) { + return function () { + var arg = arguments; + return this.forEach(function (el) { + el[methodname].apply(el, arg); + }); + }; + })(method); + } +})(); + + // EXPOSE + // SVG and VML are appended just before the EXPOSE line + // Even with AMD, Raphael should be defined globally + oldRaphael.was ? (g.win.Raphael = R) : (Raphael = R); + + if(typeof exports == "object"){ + module.exports = R; + } + return R; +})); From 7163229738c4fa534d7909ea168d0612ef89fbef Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 8 Jun 2016 11:28:35 -0600 Subject: [PATCH 085/195] Fix failing test. --- app/views/projects/network/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 3c155e97f7..e4ab064eda 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -15,5 +15,5 @@ = check_box_tag :filter_ref, 1, @options[:filter_ref] %span Begin with the selected commit - .network-graph{ data: { url: "#{escape_javascript(@url)}", commit_url: "#{escape_javascript(@commit_url)}", ref: "#{escape_javascript(@ref)}", commit_id: "#{escape_javascript(@commit.id)}" } } + .network-graph{ data: { url: '#{escape_javascript(@url)}', commit_url: '#{escape_javascript(@commit_url)}', ref: '#{escape_javascript(@ref)}', commit_id: '#{escape_javascript(@commit.id)}' } } = spinner nil, true From 853435d10b43a9d6e13351493197368bb803b01d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 9 Jun 2016 15:07:35 +0100 Subject: [PATCH 086/195] .tree-controls stacking context now above .tree-holdr Updated CHANGELOG Removed CHANGELOG entry --- app/assets/stylesheets/pages/tree.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index f16fc7f388..770bbdfc26 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -129,4 +129,6 @@ .tree-controls { float: right; margin-top: 11px; + position: relative; + z-index: 2; } From 638e318ee54b8d01773d545ff0de00dc7209d970 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 10 Jun 2016 17:04:54 +0100 Subject: [PATCH 087/195] Fixed issue when opening a highlighted line diff With the new project nav being fixed, the diff page is scrolling the highlighted under the nav meaning you cant see what is highlighted --- app/assets/javascripts/merge_request_tabs.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee index 49a4727205..894f80586f 100644 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ b/app/assets/javascripts/merge_request_tabs.js.coffee @@ -88,7 +88,7 @@ class @MergeRequestTabs scrollToElement: (container) -> if window.location.hash - navBarHeight = $('.navbar-gitlab').outerHeight() + navBarHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight() $el = $("#{container} #{window.location.hash}:not(.match)") $.scrollTo("#{container} #{window.location.hash}:not(.match)", offset: -navBarHeight) if $el.length From a9d14ddcedb7c126b8ee4942b0ca6e794ff996f8 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 9 Jun 2016 18:26:14 +0100 Subject: [PATCH 088/195] added whitespace toggle to diffs page and set it to return the project compare path with the selected whitespace params Updated CHANGELOG Moved CHANGELOG entry --- CHANGELOG | 1 + app/helpers/diff_helper.rb | 5 +++++ app/views/projects/diffs/_diffs.html.haml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a..58bd01741d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -38,6 +38,7 @@ v 8.9.0 (unreleased) - Add support for using Yubikeys (U2F) for two-factor authentication - Link to blank group icon doesn't throw a 404 anymore - Remove 'main language' feature + - Toggle whitespace button now available for compare branches diffs #17881 - Pipelines can be canceled only when there are running builds - Use downcased path to container repository as this is expected path by Docker - Projects pending deletion will render a 404 page diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index cbe4717683..e22dce59d0 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -135,6 +135,11 @@ module DiffHelper toggle_whitespace_link(url, options) end + def diff_compare_whitespace_link(project, from, to, options) + url = namespace_project_compare_path(project.namespace, project, from, to, params_with_whitespace) + toggle_whitespace_link(url, options) + end + private def hide_whitespace? diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index d9c4b410d3..1e8d99f06e 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -11,6 +11,8 @@ = commit_diff_whitespace_link(@project, @commit, class: 'hidden-xs') - elsif current_controller?(:merge_requests) = diff_merge_request_whitespace_link(@project, @merge_request, class: 'hidden-xs') + - elsif current_controller?(:compare) + = diff_compare_whitespace_link(@project, params[:from], params[:to], class: 'hidden-xs') .btn-group = inline_diff_btn = parallel_diff_btn From 52e1e03092c67392438d8fae24192e4acfb09535 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 8 Jun 2016 20:00:27 +0100 Subject: [PATCH 089/195] Updated '.event-item a' color Updated CHANGELOG Removed CHANGELOG entry --- app/assets/stylesheets/pages/events.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 6fe57c737b..dde189a21d 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -21,7 +21,7 @@ } a { - color: $gl-dark-link-color; + color: $gl-link-color; } .avatar { From 1ede0afacc1b9089ffebaa294586b4c81bddd8c8 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 7 Jun 2016 23:04:14 +0100 Subject: [PATCH 090/195] added hover state to top nav links Updated CHANGELOG Removed CHANGELOG entry --- app/assets/stylesheets/framework/nav.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index a036799e15..4c3fea4df8 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -345,6 +345,12 @@ .badge { color: $gl-icon-color; } + + &:hover { + a, i { + color: $black; + } + } } } From 20a6111d2b989e9cba9ee1106975eeb6054e01d5 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 6 Jun 2016 22:44:20 +0100 Subject: [PATCH 091/195] Added ability to skip the Mousetrap binding reset Added 'y' shortcut for copying a files immutable content hash link Updated CHANGELOG changed ! to not Moved CHANGELOG entry --- CHANGELOG | 1 + app/assets/javascripts/dispatcher.js.coffee | 1 + app/assets/javascripts/shortcuts.js.coffee | 4 ++-- app/assets/javascripts/shortcuts_blob.coffee | 10 ++++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/shortcuts_blob.coffee diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a..5e8e192538 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,7 @@ v 8.9.0 (unreleased) - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages + - Added shortcut 'y' for copying a files content hash URL #14470 - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b3..b5892dacf2 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -96,6 +96,7 @@ class Dispatcher when 'projects:blob:show', 'projects:blame:show' new LineHighlighter() shortcut_handler = new ShortcutsNavigation() + new ShortcutsBlob true when 'projects:labels:new', 'projects:labels:edit' new Labels() when 'projects:labels:index' diff --git a/app/assets/javascripts/shortcuts.js.coffee b/app/assets/javascripts/shortcuts.js.coffee index f3d6600413..c03877e9b0 100644 --- a/app/assets/javascripts/shortcuts.js.coffee +++ b/app/assets/javascripts/shortcuts.js.coffee @@ -1,7 +1,7 @@ class @Shortcuts - constructor: -> + constructor: (skipResetBindings) -> @enabledHelp = [] - Mousetrap.reset() + Mousetrap.reset() if not skipResetBindings Mousetrap.bind('?', @onToggleHelp) Mousetrap.bind('s', Shortcuts.focusSearch) Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], @toggleMarkdownPreview) diff --git a/app/assets/javascripts/shortcuts_blob.coffee b/app/assets/javascripts/shortcuts_blob.coffee new file mode 100644 index 0000000000..6d21e5d115 --- /dev/null +++ b/app/assets/javascripts/shortcuts_blob.coffee @@ -0,0 +1,10 @@ +#= require shortcuts + +class @ShortcutsBlob extends Shortcuts + constructor: (skipResetBindings) -> + super skipResetBindings + Mousetrap.bind('y', ShortcutsBlob.copyToClipboard) + + @copyToClipboard: -> + clipboardButton = $('.btn-clipboard') + clipboardButton.click() if clipboardButton From 479ecbab9b859b829a9ff15d5eba4fa641d0bfaa Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lukeeeebennettplus@gmail.com> Date: Wed, 1 Jun 2016 00:57:44 +0100 Subject: [PATCH 092/195] Tidied dispatcher switch and added shortcuts to project pipelines, milestones and forks pages Updated CHANGELOG Moved CHANGELOG entry --- CHANGELOG | 1 + app/assets/javascripts/dispatcher.js.coffee | 16 +++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a..5969e18701 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -32,6 +32,7 @@ v 8.9.0 (unreleased) - Changed the Slack build message to use the singular duration if necessary (Aran Koning) - Links from a wiki page to other wiki pages should be rewritten as expected - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos) + - Added navigation shortcuts to the project pipelines, milestones, builds and forks page. !4393 - Fix issues filter when ordering by milestone - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b3..4c50e54098 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -68,9 +68,7 @@ class Dispatcher new Diff() new ZenMode() shortcut_handler = new ShortcutsNavigation() - when 'projects:commits:show' - shortcut_handler = new ShortcutsNavigation() - when 'projects:activity' + when 'projects:commits:show', 'projects:activity' shortcut_handler = new ShortcutsNavigation() when 'projects:show' shortcut_handler = new ShortcutsNavigation() @@ -129,15 +127,11 @@ class Dispatcher new Project() new ProjectAvatar() switch path[1] - when 'compare' - shortcut_handler = new ShortcutsNavigation() when 'edit' shortcut_handler = new ShortcutsNavigation() new ProjectNew() - when 'new' + when 'new', 'show' new ProjectNew() - when 'show' - new ProjectShow() when 'wikis' new Wikis() shortcut_handler = new ShortcutsNavigation() @@ -146,9 +140,9 @@ class Dispatcher when 'snippets' shortcut_handler = new ShortcutsNavigation() new ZenMode() if path[2] == 'show' - when 'labels', 'graphs' - shortcut_handler = new ShortcutsNavigation() - when 'project_members', 'deploy_keys', 'hooks', 'services', 'protected_branches' + when 'labels', 'graphs', 'compare', 'pipelines', 'forks', \ + 'milestones', 'project_members', 'deploy_keys', 'builds', \ + 'hooks', 'services', 'protected_branches' shortcut_handler = new ShortcutsNavigation() # If we haven't installed a custom shortcut handler, install the default one From 30bf8dcc144784a3f8bc37b3a98bf8e393d05953 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lukeeeebennettplus@gmail.com> Date: Wed, 1 Jun 2016 00:07:05 +0100 Subject: [PATCH 093/195] Pipeline artifacts download button wording improved Updated CHANGELOG Removed CHANGELOG entry --- app/views/projects/ci/pipelines/_pipeline.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index a0ffa06506..b8d8758fd2 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -60,7 +60,7 @@ %li = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, build), rel: 'nofollow' do = icon("download") - %span #{build.name} + %span Download '#{build.name}' artifacts - if can?(current_user, :update_pipeline, @project) - if pipeline.retryable? From bcc3f8f237f5cf2b64088564637f8bb22d3522c8 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Sat, 11 Jun 2016 00:24:24 +0300 Subject: [PATCH 094/195] Fix emoji block selector. --- app/assets/javascripts/awards_handler.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee index 136db8ee14..030f156486 100644 --- a/app/assets/javascripts/awards_handler.coffee +++ b/app/assets/javascripts/awards_handler.coffee @@ -40,7 +40,7 @@ class @AwardsHandler $menu = $ '.emoji-menu' if $addBtn.hasClass 'js-note-emoji' - $addBtn.parents('.note').find('.js-awards-block').addClass 'current' + $addBtn.closest('.note').find('.js-awards-block').addClass 'current' else $addBtn.closest('.js-awards-block').addClass 'current' From 8c367c918d3c439f8dad46ed1c3f3d661da1a88c Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 11:41:58 +0100 Subject: [PATCH 095/195] Shows build scroll buttons after build is complete Closes #18515 --- app/views/projects/builds/show.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index a26f8aeb31..4e2702c2e4 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -48,16 +48,16 @@ - if @build.active? .autoscroll-container %button.btn.btn-success.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} enable autoscroll - #js-build-scroll.scroll-controls - = link_to '#build-trace', class: 'btn' do - %i.fa.fa-angle-up - = link_to '#down-build-trace', class: 'btn' do - %i.fa.fa-angle-down - if @build.erased? .erased.alert.alert-warning - erased_by = "by #{link_to @build.erased_by.name, user_path(@build.erased_by)}" if @build.erased_by Build has been erased #{erased_by.html_safe} #{time_ago_with_tooltip(@build.erased_at)} - else + #js-build-scroll.scroll-controls + = link_to '#build-trace', class: 'btn' do + %i.fa.fa-angle-up + = link_to '#down-build-trace', class: 'btn' do + %i.fa.fa-angle-down %pre.build-trace#build-trace %code.bash.js-build-output = icon("refresh spin", class: "js-build-refresh") From 22e97dd702182aa51586972bb54861ee8b19846b Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 8 Jun 2016 17:04:41 +0100 Subject: [PATCH 096/195] Fixed issue with MR buttons being in a group Also removed some inline code --- app/assets/javascripts/dispatcher.js.coffee | 4 ++ .../javascripts/merged_buttons.js.coffee | 30 +++++++++ .../stylesheets/pages/merge_requests.scss | 10 +++ app/helpers/commits_helper.rb | 6 +- .../merge_requests/widget/_merged.html.haml | 61 +++++++------------ .../widget/_merged_buttons.haml | 4 +- 6 files changed, 71 insertions(+), 44 deletions(-) create mode 100644 app/assets/javascripts/merged_buttons.js.coffee diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b3..8b39e6b090 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -53,9 +53,13 @@ class Dispatcher new Diff() shortcut_handler = new ShortcutsIssuable(true) new ZenMode() + new MergedButtons() + when 'projects:merge_requests:commits', 'projects:merge_requests:builds' + new MergedButtons() when "projects:merge_requests:diffs" new Diff() new ZenMode() + new MergedButtons() when 'projects:merge_requests:index' shortcut_handler = new ShortcutsNavigation() Issuable.init() diff --git a/app/assets/javascripts/merged_buttons.js.coffee b/app/assets/javascripts/merged_buttons.js.coffee new file mode 100644 index 0000000000..4929295c10 --- /dev/null +++ b/app/assets/javascripts/merged_buttons.js.coffee @@ -0,0 +1,30 @@ +class @MergedButtons + constructor: -> + @$removeBranchWidget = $('.remove_source_branch_widget') + @$removeBranchProgress = $('.remove_source_branch_in_progress') + @$removeBranchFailed = $('.remove_source_branch_widget.failed') + + @cleanEventListeners() + @initEventListeners() + + cleanEventListeners: -> + $(document).off 'click', '.remove_source_branch' + $(document).off 'ajax:success', '.remove_source_branch' + $(document).off 'ajax:error', '.remove_source_branch' + + initEventListeners: -> + $(document).on 'click', '.remove_source_branch', @removeSourceBranch + $(document).on 'ajax:success', '.remove_source_branch', @removeBranchSuccess + $(document).on 'ajax:error', '.remove_source_branch', @removeBranchError + + removeSourceBranch: => + @$removeBranchWidget.hide() + @$removeBranchProgress.show() + + removeBranchSuccess: -> + location.reload() + + removeBranchError: -> + @$removeBranchWidget.hide() + @$removeBranchProgress.hide() + @$removeBranchFailed.show() diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index a47f2580aa..53bff508c7 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -313,3 +313,13 @@ } } } + +.merged-buttons { + .btn { + float: left; + + &:not(:last-child) { + margin-right: 10px; + } + } +} diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d328f56c80..493505e0c9 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -129,7 +129,7 @@ module CommitsHelper tooltip = "Revert this #{commit.change_type_title} in a new merge request" if has_tooltip if can_collaborate_with_project? - btn_class = "btn btn-grouped btn-close btn-#{btn_class}" unless btn_class.nil? + btn_class = "btn btn-warning btn-#{btn_class}" unless btn_class.nil? link_to 'Revert', '#modal-revert-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { @@ -141,7 +141,7 @@ module CommitsHelper namespace_key: current_user.namespace.id, continue: continue_params) - btn_class = "btn btn-grouped btn-close" unless btn_class.nil? + btn_class = "btn btn-grouped btn-warning" unless btn_class.nil? link_to 'Revert', fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip) end @@ -153,7 +153,7 @@ module CommitsHelper tooltip = "Cherry-pick this #{commit.change_type_title} in a new merge request" if can_collaborate_with_project? - btn_class = "btn btn-default btn-grouped btn-#{btn_class}" unless btn_class.nil? + btn_class = "btn btn-default btn-#{btn_class}" unless btn_class.nil? link_to 'Cherry-pick', '#modal-cherry-pick-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml index ec4beae972..19b5d0ff06 100644 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ b/app/views/projects/merge_requests/widget/_merged.html.haml @@ -6,46 +6,29 @@ - if @merge_request.merge_event by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - %div - - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + %p + The changes were merged into + #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. + The source branch has been removed. + = render 'projects/merge_requests/widget/merged_buttons' + - elsif @merge_request.can_remove_source_branch?(current_user) + .remove_source_branch_widget %p The changes were merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget - %p - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.hide - %p - Failed to remove source branch '#{@merge_request.source_branch}'. - - .remove_source_branch_in_progress.hide - %p - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded. - - :javascript - $('.remove_source_branch').on('click', function() { - $('.remove_source_branch_widget').hide(); - $('.remove_source_branch_in_progress').show(); - }); - - $(".remove_source_branch").on("ajax:success", function (e, data, status, xhr) { - location.reload(); - }); - - $(".remove_source_branch").on("ajax:error", function (e, data, status, xhr) { - $('.remove_source_branch_widget').hide(); - $('.remove_source_branch_in_progress').hide(); - $('.remove_source_branch_widget.failed').show(); - }); - - else + You can remove the source branch now. + = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true + .remove_source_branch_widget.failed.hide %p - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - = render 'projects/merge_requests/widget/merged_buttons' + Failed to remove source branch '#{@merge_request.source_branch}'. + + .remove_source_branch_in_progress.hide + %p + = icon('spinner spin') + Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded. + - else + %p + The changes were merged into + #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. + = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml index 56167509af..d836a25350 100644 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ b/app/views/projects/merge_requests/widget/_merged_buttons.haml @@ -3,9 +3,9 @@ - mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .btn-group + .clearfix.merged-buttons - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default btn-grouped btn-sm remove_source_branch" do + = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default btn-sm remove_source_branch" do = icon('trash-o') Remove Source Branch - if mr_can_be_reverted From b63dc993534a567b7aba737db1565e3b56033ba2 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Mon, 13 Jun 2016 19:18:15 +0300 Subject: [PATCH 097/195] Defensive check for the group options. --- app/views/layouts/_search.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 5c6429d07b..4587cf5065 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -44,7 +44,7 @@ name: "#{@project.name}" }; - - if @group + - if @group and @group.path :javascript gl.groupOptions = gl.groupOptions || {}; gl.groupOptions["#{@group.path}"] = { From f866af766e5ffe461b835e0071f47e9668d4f93d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 13 Jun 2016 17:52:34 +0100 Subject: [PATCH 098/195] Fixed notes action buttons --- app/assets/stylesheets/pages/notes.scss | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 0c08411875..35d728aec8 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -139,6 +139,12 @@ ul.notes { @media (min-width: $screen-sm-min) { padding-right: 0; } + + @media (max-width: $screen-xs-min) { + .inline { + display: block; + } + } } .note-emoji-button { @@ -258,7 +264,11 @@ ul.notes { position: absolute; right: 0; top: 0; - + + .note-action-button { + margin-left: 10px; + } + @media (min-width: $screen-sm-min) { position: relative; } From ee2e583500360385c9b3f8d9231233223ab72b42 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 21:52:41 +0200 Subject: [PATCH 099/195] Fair usage of Shared Runners --- CHANGELOG | 1 + app/services/ci/register_build_service.rb | 25 +++++++++--- .../ci/register_build_service_spec.rb | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919d..3c1a55d777 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -19,6 +19,7 @@ v 8.9.0 (unreleased) - Added descriptions to notification settings dropdown - Improve note validation to prevent errors when creating invalid note via API - Reduce number of fog gem dependencies + - Implement a fair usage of shared runners - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 4ff268a6f0..54aceba1c8 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -7,15 +7,15 @@ module Ci builds = if current_runner.shared? - # don't run projects which have not enables shared runners - builds.joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }) + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + builds.joins("JOIN (#{projects_with_builds_for_shared_runners.to_sql}) AS projects ON ci_builds.gl_project_id=projects.gl_project_id"). + order('projects.running_builds ASC', 'ci_builds.id ASC') else - # do run projects which are only assigned to this runner - builds.where(project: current_runner.projects.where(builds_enabled: true)) + # do run projects which are only assigned to this runner (FIFO) + builds.where(project: current_runner.projects.where(builds_enabled: true)).order('created_at ASC') end - builds = builds.order('created_at ASC') - build = builds.find do |build| build.can_be_served?(current_runner) end @@ -35,5 +35,18 @@ module Ci rescue StateMachines::InvalidTransition nil end + + private + + def projects_with_builds_for_shared_runners + Ci::Build.running_or_pending. + joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). + group(:gl_project_id). + select(:gl_project_id, "count(case when status = 'running' AND runner_id = (#{shared_runners.to_sql}) then 1 end) as running_builds") + end + + def shared_runners + Ci::Runner.shared.select(:id) + end end end diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_build_service_spec.rb index d91fc57429..fa4c2fddeb 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_build_service_spec.rb @@ -50,6 +50,46 @@ module Ci project.update(shared_runners_enabled: true) end + context 'for multiple builds' do + let!(:project2) { create :empty_project, shared_runners_enabled: true } + let!(:pipeline2) { create :ci_pipeline, project: project2 } + let!(:project3) { create :empty_project, shared_runners_enabled: true } + let!(:pipeline3) { create :ci_pipeline, project: project3 } + let!(:build1_project1) { pending_build } + let!(:build2_project1) { FactoryGirl.create :ci_build, pipeline: pipeline } + let!(:build3_project1) { FactoryGirl.create :ci_build, pipeline: pipeline } + let!(:build1_project2) { FactoryGirl.create :ci_build, pipeline: pipeline2 } + let!(:build2_project2) { FactoryGirl.create :ci_build, pipeline: pipeline2 } + let!(:build1_project3) { FactoryGirl.create :ci_build, pipeline: pipeline3 } + + it 'prefers projects without builds first' do + # it gets for one build from each of the projects + expect(service.execute(shared_runner)).to eq(build1_project1) + expect(service.execute(shared_runner)).to eq(build1_project2) + expect(service.execute(shared_runner)).to eq(build1_project3) + + # then it gets a second build from each of the projects + expect(service.execute(shared_runner)).to eq(build2_project1) + expect(service.execute(shared_runner)).to eq(build2_project2) + + # in the end the third build + expect(service.execute(shared_runner)).to eq(build3_project1) + end + + it 'equalises number of running builds' do + # after finishing the first build for project 1, get a second build from the same project + expect(service.execute(shared_runner)).to eq(build1_project1) + build1_project1.success + expect(service.execute(shared_runner)).to eq(build2_project1) + + expect(service.execute(shared_runner)).to eq(build1_project2) + build1_project2.success + expect(service.execute(shared_runner)).to eq(build2_project2) + expect(service.execute(shared_runner)).to eq(build1_project3) + expect(service.execute(shared_runner)).to eq(build3_project1) + end + end + context 'shared runner' do let(:build) { service.execute(shared_runner) } From 6f8626de0609da6c789457153b2b19dc79db2c95 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 00:07:18 +0300 Subject: [PATCH 100/195] Escape JavaScript in haml template. --- app/views/layouts/_search.html.haml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 4587cf5065..245b9c3b4d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -38,19 +38,19 @@ :javascript gl.projectOptions = gl.projectOptions || {}; - gl.projectOptions["#{@project.path}"] = { + gl.projectOptions["#{j(@project.path)}"] = { issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", - name: "#{@project.name}" + name: "#{j(@project.name)}" }; - if @group and @group.path :javascript gl.groupOptions = gl.groupOptions || {}; - gl.groupOptions["#{@group.path}"] = { - name: "#{@group.name}", - issuesPath: "#{issues_group_path(@group.path)}", - mrPath: "#{merge_requests_group_path(@group.path)}" + gl.groupOptions["#{j(@group.path)}"] = { + name: "#{j(@group.name)}", + issuesPath: "#{issues_group_path(j(@group.path))}", + mrPath: "#{merge_requests_group_path(j(@group.path))}" }; From 385eff573eb300a267af96df153a489ebdf12886 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 01:12:22 +0300 Subject: [PATCH 101/195] Updated CHANGELOG and template. --- CHANGELOG | 2 +- app/assets/javascripts/gfm_auto_complete.js.coffee | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f2e9733509..1b6bfd2229 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -47,6 +47,7 @@ v 8.9.0 (unreleased) - Use downcased path to container repository as this is expected path by Docker - Projects pending deletion will render a 404 page - Measure queue duration between gitlab-workhorse and Rails + - Added Gfm autocomplete for labels - Make Omniauth providers specs to not modify global configuration - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) @@ -164,7 +165,6 @@ v 8.8.0 - Support multi-line tag messages. !3833 (Calin Seciu) - Force users to reset their password after an admin changes it - Allow "NEWS" and "CHANGES" as alternative names for CHANGELOG. !3768 (Connor Shea) - - Added Gfm autocomplete for labels - Added button to toggle whitespaces changes on diff view - Backport GitHub Enterprise import support from EE - Create tags using Rugged for performance reasons. !3745 diff --git a/app/assets/javascripts/gfm_auto_complete.js.coffee b/app/assets/javascripts/gfm_auto_complete.js.coffee index da58be5185..190bb38504 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.coffee +++ b/app/assets/javascripts/gfm_auto_complete.js.coffee @@ -16,8 +16,7 @@ GitLab.GfmAutoComplete = template: '<li>${username} <small>${title}</small></li>' Labels: - template: '<li>${title} <div style="background-color:${color};height:15px;width:15px;display:inline-block;float:right"> - </div></li>' + template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' # Issues and MergeRequests Issues: From 1685b9dc2eccdabeea2dbe61d4f9fb28d06f9c3c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 00:14:30 +0200 Subject: [PATCH 102/195] Optimise SQL query --- app/services/ci/register_build_service.rb | 24 +++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 54aceba1c8..9583f6c7c4 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -7,10 +7,14 @@ module Ci builds = if current_runner.shared? - # this returns builds that are ordered by number of running builds - # we prefer projects that don't use shared runners at all - builds.joins("JOIN (#{projects_with_builds_for_shared_runners.to_sql}) AS projects ON ci_builds.gl_project_id=projects.gl_project_id"). - order('projects.running_builds ASC', 'ci_builds.id ASC') + builds. + # don't run projects which have not enables shared runners + joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). + + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.gl_project_id=project_builds.gl_project_id"). + order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') else # do run projects which are only assigned to this runner (FIFO) builds.where(project: current_runner.projects.where(builds_enabled: true)).order('created_at ASC') @@ -38,15 +42,9 @@ module Ci private - def projects_with_builds_for_shared_runners - Ci::Build.running_or_pending. - joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). - group(:gl_project_id). - select(:gl_project_id, "count(case when status = 'running' AND runner_id = (#{shared_runners.to_sql}) then 1 end) as running_builds") - end - - def shared_runners - Ci::Runner.shared.select(:id) + def running_builds_for_shared_runners + Ci::Build.running.where(runner: Ci::Runner.shared). + group(:gl_project_id).select(:gl_project_id, 'count(*) AS running_builds') end end end From be04becfb39e853f17c52a25dc2e0c2f97eb9284 Mon Sep 17 00:00:00 2001 From: Arinde Eniola <eniolaarinde1@gmail.com> Date: Mon, 2 May 2016 18:15:00 +0100 Subject: [PATCH 103/195] show number of processed mrs in milestone page --- app/views/shared/milestones/_merge_requests_tab.haml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml index c29d8ee673..9c193f901e 100644 --- a/app/views/shared/milestones/_merge_requests_tab.haml +++ b/app/views/shared/milestones/_merge_requests_tab.haml @@ -3,10 +3,10 @@ .row.prepend-top-default .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned') + = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing') + = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed') + = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true) + = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true) From f4f30908a77738b7966ea50b89c1232540fd0ee3 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 04:09:26 +0300 Subject: [PATCH 104/195] Fix long commit message scroll issue. Fixes #18481. --- app/assets/stylesheets/pages/commits.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c8c6bbde08..f8edf7d601 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -93,6 +93,7 @@ li.commit { background: inherit; padding: 0; margin: 0; + white-space: pre-wrap; } a { From faa0e3f7580bc38d4d12916b4589c64d6c2678a7 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Tue, 14 Jun 2016 09:50:14 +0530 Subject: [PATCH 105/195] Fix rubocop spec. --- db/migrate/20160415062917_create_personal_access_tokens.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20160415062917_create_personal_access_tokens.rb b/db/migrate/20160415062917_create_personal_access_tokens.rb index 2c9f773e30..ce0b33f32b 100644 --- a/db/migrate/20160415062917_create_personal_access_tokens.rb +++ b/db/migrate/20160415062917_create_personal_access_tokens.rb @@ -2,7 +2,7 @@ class CreatePersonalAccessTokens < ActiveRecord::Migration def change create_table :personal_access_tokens do |t| t.references :user, index: true, foreign_key: true, null: false - t.string :token, index: {unique: true}, null: false + t.string :token, index: { unique: true }, null: false t.string :name, null: false t.boolean :revoked, default: false t.datetime :expires_at From 2a82684b4780559367a2afba6bb95d28a622ee59 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 14:08:02 +0300 Subject: [PATCH 106/195] Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 3387394de5..093806d2c1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -23,6 +23,7 @@ v 8.9.0 (unreleased) - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages - Fix groups API to list only user's accessible projects + - Fix horizontal scrollbar for long commit message. - Redesign account and email confirmation emails - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 From b8beb0b8aceb003b940a3a4695b3e7cb67216e5f Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 14 Jun 2016 13:18:19 +0100 Subject: [PATCH 107/195] Fixed last push event banner not being in container Closes #18567 --- app/assets/stylesheets/framework/blocks.scss | 4 ++++ app/views/projects/_last_push.html.haml | 22 ++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index fab96404a6..d5fe5bc2ef 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -91,6 +91,10 @@ background-color: $white-light; border-top: none; } + + &.top-block .container-fluid { + background-color: inherit; + } } .cover-block { diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 7c2b8d0150..e0ca2a3109 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -1,15 +1,15 @@ - if event = last_push_event - if show_last_push_widget?(event) - .row-content-block.top-block.clear-block.hidden-xs - .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 - %strong= event.ref_name - branch - #{time_ago_with_tooltip(event.created_at)} + %div{ class: (container_class) } + .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 + %strong= event.ref_name + branch + #{time_ago_with_tooltip(event.created_at)} - .pull-right - = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do - Create Merge Request + .pull-right + = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do + Create Merge Request From e45fd5a1e4efb805d3f7f5ed9cb708105c4e9d60 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 14 Jun 2016 14:21:24 +0200 Subject: [PATCH 108/195] Remove ci commit specs that remain after bad merge --- spec/models/ci/commit_spec.rb | 403 ---------------------------------- 1 file changed, 403 deletions(-) delete mode 100644 spec/models/ci/commit_spec.rb diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb deleted file mode 100644 index 01d931b087..0000000000 --- a/spec/models/ci/commit_spec.rb +++ /dev/null @@ -1,403 +0,0 @@ -require 'spec_helper' - -describe Ci::Commit, models: true do - let(:project) { FactoryGirl.create :empty_project } - 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(:trigger_requests) } - it { is_expected.to have_many(:builds) } - it { is_expected.to validate_presence_of :sha } - it { is_expected.to validate_presence_of :status } - - it { is_expected.to respond_to :git_author_name } - it { is_expected.to respond_to :git_author_email } - it { is_expected.to respond_to :short_sha } - - describe :valid_commit_sha do - context 'commit.sha can not start with 00000000' do - before do - commit.sha = '0' * 40 - commit.valid_commit_sha - end - - it('commit errors should not be empty') { expect(commit.errors).not_to be_empty } - end - end - - describe :short_sha do - subject { commit.short_sha } - - it 'has 8 items' do - expect(subject.size).to eq(8) - end - it { expect(commit.sha).to start_with(subject) } - end - - describe :create_next_builds do - end - - describe :retried do - subject { commit.retried } - - before do - @commit1 = FactoryGirl.create :ci_build, commit: commit, name: 'deploy' - @commit2 = FactoryGirl.create :ci_build, commit: commit, name: 'deploy' - end - - it 'returns old builds' do - is_expected.to contain_exactly(@commit1) - end - end - - describe :create_builds do - let!(:commit) { FactoryGirl.create :ci_commit, project: project, ref: 'master', tag: false } - - def create_builds(trigger_request = nil) - commit.create_builds(nil, trigger_request) - end - - def create_next_builds - commit.create_next_builds(commit.builds.order(:id).last) - end - - it 'creates builds' do - expect(create_builds).to be_truthy - commit.builds.update_all(status: "success") - expect(commit.builds.count(:all)).to eq(2) - - expect(create_next_builds).to be_truthy - commit.builds.update_all(status: "success") - expect(commit.builds.count(:all)).to eq(4) - - expect(create_next_builds).to be_truthy - commit.builds.update_all(status: "success") - expect(commit.builds.count(:all)).to eq(5) - - expect(create_next_builds).to be_falsey - end - - context 'custom stage with first job allowed to fail' do - let(:yaml) do - { - stages: ['clean', 'test'], - clean_job: { - stage: 'clean', - allow_failure: true, - script: 'BUILD', - }, - test_job: { - stage: 'test', - script: 'TEST', - }, - } - end - - before do - stub_ci_commit_yaml_file(YAML.dump(yaml)) - create_builds - end - - it 'properly schedules builds' do - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:drop) - expect(commit.builds.pluck(:status)).to contain_exactly('pending', 'failed') - end - end - - context 'properly creates builds when "when" is defined' do - let(:yaml) do - { - stages: ["build", "test", "test_failure", "deploy", "cleanup"], - build: { - stage: "build", - script: "BUILD", - }, - test: { - stage: "test", - script: "TEST", - }, - test_failure: { - stage: "test_failure", - script: "ON test failure", - when: "on_failure", - }, - deploy: { - stage: "deploy", - script: "PUBLISH", - }, - cleanup: { - stage: "cleanup", - script: "TIDY UP", - when: "always", - } - } - end - - before do - stub_ci_commit_yaml_file(YAML.dump(yaml)) - end - - context 'when builds are successful' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'success') - commit.reload - expect(commit.status).to eq('success') - end - end - - context 'when test job fails' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'success') - commit.reload - expect(commit.status).to eq('failed') - end - end - - context 'when test and test_failure jobs fail' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'success') - commit.reload - expect(commit.status).to eq('failed') - end - end - - context 'when deploy job fails' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'success') - commit.reload - expect(commit.status).to eq('failed') - end - end - - context 'when build is canceled in the second stage' do - it 'does not schedule builds after build has been canceled' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.running_or_pending).not_to be_empty - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:cancel) - - expect(commit.builds.running_or_pending).to be_empty - expect(commit.reload.status).to eq('canceled') - end - end - end - end - - describe "#finished_at" do - let(:commit) { FactoryGirl.create :ci_commit } - - it "returns finished_at of latest build" do - build = FactoryGirl.create :ci_build, commit: commit, finished_at: Time.now - 60 - FactoryGirl.create :ci_build, commit: commit, finished_at: Time.now - 120 - - expect(commit.finished_at.to_i).to eq(build.finished_at.to_i) - end - - it "returns nil if there is no finished build" do - FactoryGirl.create :ci_not_started_build, commit: commit - - expect(commit.finished_at).to be_nil - end - end - - describe "coverage" do - let(:project) { FactoryGirl.create :empty_project, build_coverage_regex: "/.*/" } - let(:commit) { FactoryGirl.create :ci_commit, project: project } - - it "calculates average when there are two builds with coverage" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, commit: commit - expect(commit.coverage).to eq("35.00") - end - - it "calculates average when there are two builds with coverage and one with nil" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, commit: commit - FactoryGirl.create :ci_build, commit: commit - expect(commit.coverage).to eq("35.00") - end - - it "calculates average when there are two builds with coverage and one is retried" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, commit: commit - expect(commit.coverage).to eq("35.00") - end - - it "calculates average when there is one build without coverage" do - FactoryGirl.create :ci_build, commit: commit - expect(commit.coverage).to be_nil - end - end - - describe '#retryable?' do - subject { commit.retryable? } - - context 'no failed builds' do - before do - FactoryGirl.create :ci_build, name: "rspec", commit: commit, status: 'success' - end - - it 'be not retryable' do - is_expected.to be_falsey - end - end - - context 'with failed builds' do - before do - FactoryGirl.create :ci_build, name: "rspec", commit: commit, status: 'running' - FactoryGirl.create :ci_build, name: "rubocop", commit: commit, status: 'failed' - end - - it 'be retryable' do - is_expected.to be_truthy - end - end - end - - describe '#stages' do - let(:commit2) { FactoryGirl.create :ci_commit, project: project } - subject { CommitStatus.where(commit: [commit, commit2]).stages } - - before do - FactoryGirl.create :ci_build, commit: commit2, stage: 'test', stage_idx: 1 - FactoryGirl.create :ci_build, commit: commit, stage: 'build', stage_idx: 0 - end - - it 'return all stages' do - is_expected.to eq(%w(build test)) - end - end - - describe '#update_state' do - it 'execute update_state after touching object' do - expect(commit).to receive(:update_state).and_return(true) - commit.touch - end - - context 'dependent objects' do - let(:commit_status) { build :commit_status, commit: commit } - - it 'execute update_state after saving dependent object' do - expect(commit).to receive(:update_state).and_return(true) - commit_status.save - end - end - - context 'update state' do - let(:current) { Time.now.change(usec: 0) } - let!(:build) do - create :ci_build, :success, commit: commit, - started_at: current - 120, - finished_at: current - 60 - end - - [:status, :started_at, :finished_at, :duration].each do |param| - it "update #{param}" do - expect(commit.send(param)).to eq(build.send(param)) - end - end - end - end - - describe '#branch?' do - subject { commit.branch? } - - context 'is not a tag' do - before do - commit.tag = false - end - - it 'return true when tag is set to false' do - is_expected.to be_truthy - end - end - - context 'is not a tag' do - before do - commit.tag = true - end - - it 'return false when tag is set to true' do - is_expected.to be_falsey - end - end - end -end From cf292a3f1d3cc17d55131a15d91a53ff31017f5d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 14 Jun 2016 14:09:07 +0200 Subject: [PATCH 109/195] Improve code clarity in pipeline create service --- app/models/ci/pipeline.rb | 2 +- app/services/create_commit_builds_service.rb | 52 ++++++++++++++----- .../create_commit_builds_service_spec.rb | 4 +- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 83d683b63e..63639ff2c1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -100,7 +100,7 @@ module Ci def create_builds(user, trigger_request = nil) build_builds(user, 'success', trigger_request) - save! + save end def create_next_builds(build) diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index f2a537c595..668d0a8654 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -14,26 +14,50 @@ class CreateCommitBuildsService return false end - pipeline = Ci::Pipeline.new(project: project, sha: sha, ref: ref, before_sha: before_sha, tag: tag) + @pipeline = Ci::Pipeline.new(project: project, sha: sha, ref: ref, before_sha: before_sha, tag: tag) - # Skip creating pipeline when no gitlab-ci.yml is found - unless pipeline.ci_yaml_file - return pipeline + ## + # Skip creating pipeline if no gitlab-ci.yml is found + # + unless @pipeline.ci_yaml_file + return false end + ## # Skip creating builds for commits that have [ci skip] - if !pipeline.skip_ci? && pipeline.config_processor - # Create builds for commit - unless pipeline.build_builds(user) - pipeline.errors.add(:base, 'No builds created') - return pipeline - end + # but save pipeline object + # + if @pipeline.skip_ci? + return save_pipeline! end - # Create a new pipeline - pipeline.save! + ## + # Skip creating builds when CI config is invalid + # but save pipeline object + # + unless @pipeline.config_processor + return save_pipeline! + end - pipeline.touch - pipeline + ## + # Skip creating pipeline object if there are no builds for it. + # + unless @pipeline.build_builds(user) + @pipeline.errors.add(:base, 'No builds created') + return false + end + + save_pipeline! + end + + private + + ## + # Create a new pipeline and touch object to calculate status + # + def save_pipeline! + @pipeline.save! + @pipeline.touch + @pipeline end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 08cbc9beb5..50ce9659c1 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -60,7 +60,7 @@ describe CreateCommitBuildsService, services: true do after: '31das312', commits: [{ message: 'Message' }] ) - expect(result).not_to be_persisted + expect(result).to be_falsey expect(Ci::Pipeline.count).to eq(0) end @@ -184,7 +184,7 @@ describe CreateCommitBuildsService, services: true do before: '00000000', after: '31das312', commits: [{ message: 'some msg' }]) - expect(result).not_to be_persisted + expect(result).to be_falsey expect(Ci::Build.all).to be_empty expect(Ci::Pipeline.count).to eq(0) end From 42eab8348f225894f94f1edef1c3e5c3322c1b10 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 14 Jun 2016 14:45:01 +0100 Subject: [PATCH 110/195] Fixed alignment of download dropdown --- CHANGELOG | 1 + app/views/projects/merge_requests/_show.html.haml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e71a154d1d..2bc9def1ad 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.9.0 (unreleased) - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 + - Fixed alignment of download dropdown in merge requests - Upgrade to jQuery 2 - Use Knapsack to evenly distribute tests across multiple nodes - Add `sha` parameter to MR merge API, to ensure only reviewed changes are merged diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index c4df8bd504..b60bbb7532 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -17,11 +17,11 @@ = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do Check out branch - %span.dropdown + %span.dropdown.inline.prepend-left-5 %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } Download as %span.caret - %ul.dropdown-menu + %ul.dropdown-menu.dropdown-menu-align-right %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) .normal From 7b4e0739e6834cfe192012059163af523dcae798 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Tue, 14 Jun 2016 22:13:58 -0300 Subject: [PATCH 111/195] Project members with guest role can't access notes on confidential issues --- spec/finders/notes_finder_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 639b28d49e..1bd354815e 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -49,6 +49,13 @@ describe NotesFinder do user = create(:user) expect { NotesFinder.new.execute(project, user, params) }.to raise_error(ActiveRecord::RecordNotFound) end + + it 'raises an error for project members with guest role' do + user = create(:user) + project.team << [user, :guest] + + expect { NotesFinder.new.execute(project, user, params) }.to raise_error(ActiveRecord::RecordNotFound) + end end end end From bf990fcda4c5b728272d3775cdefadce6f80cf01 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 09:35:11 +0200 Subject: [PATCH 112/195] Return false in create_builds if not builds created This fixes compatibility with trigger request create service. --- app/models/ci/pipeline.rb | 5 ++--- spec/models/ci/pipeline_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 63639ff2c1..e90924af31 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -91,7 +91,7 @@ module Ci trigger_requests.any? end - def build_builds(user, status = 'success', trigger_request = nil) + def build_builds(user, trigger_request = nil, status = 'success') return unless config_processor config_processor.stages.any? do |stage| build_builds_for_stage(stage, user, status, trigger_request).present? @@ -99,8 +99,7 @@ module Ci end def create_builds(user, trigger_request = nil) - build_builds(user, 'success', trigger_request) - save + build_builds(user, trigger_request) && save end def create_next_builds(build) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 0d769ed732..458013ad9f 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -258,6 +258,16 @@ describe Ci::Pipeline, models: true do end end end + + context 'when no builds created' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(before_script: ['ls'])) + end + + it 'returns false' do + expect(pipeline.create_builds(nil)).to be_falsey + end + end end describe "#finished_at" do From a76cbe5292d20cd6fdac4e519b65df1ee3544371 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 10:05:36 +0200 Subject: [PATCH 113/195] Add note for short circuit eval when building builds --- app/models/ci/pipeline.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index e90924af31..fde03f21f9 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -93,6 +93,11 @@ module Ci def build_builds(user, trigger_request = nil, status = 'success') return unless config_processor + + ## + # Note that `Array#any?` implements a short circuit evaluation, so we + # build builds only for the first stage that has builds available. + # config_processor.stages.any? do |stage| build_builds_for_stage(stage, user, status, trigger_request).present? end @@ -117,9 +122,14 @@ module Ci prior_builds = latest_builds.where.not(stage: next_stages) prior_status = prior_builds.status - # create builds for next stages based + ## + # Create builds for next stages based. + # + # Note that there is a short circult evaluation here. + # have_builds = next_stages.any? do |stage| - build_builds_for_stage(stage, build.user, prior_status, build.trigger_request).present? + build_builds_for_stage(stage, build.user, prior_status, + build.trigger_request).present? end save! if have_builds From 6ff146340fea6d0df1b711933b0399fbf324e861 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 10:22:20 +0200 Subject: [PATCH 114/195] Improve creating builds by combining two loops --- app/models/ci/pipeline.rb | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index fde03f21f9..58c6925182 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -91,16 +91,11 @@ module Ci trigger_requests.any? end - def build_builds(user, trigger_request = nil, status = 'success') + def build_builds(user, trigger_request = nil) return unless config_processor - ## - # Note that `Array#any?` implements a short circuit evaluation, so we - # build builds only for the first stage that has builds available. - # - config_processor.stages.any? do |stage| - build_builds_for_stage(stage, user, status, trigger_request).present? - end + build_builds_for_stages(config_processor.stages, user, + 'success', trigger_request) end def create_builds(user, trigger_request = nil) @@ -122,17 +117,11 @@ module Ci prior_builds = latest_builds.where.not(stage: next_stages) prior_status = prior_builds.status - ## - # Create builds for next stages based. - # - # Note that there is a short circult evaluation here. - # - have_builds = next_stages.any? do |stage| - build_builds_for_stage(stage, build.user, prior_status, - build.trigger_request).present? - end + # build builds for next stage that has builds available + # and save pipeline if we have builds + build_builds_for_stages(next_stages, build.user, prior_status, + build.trigger_request) && save - save! if have_builds end def retried @@ -179,8 +168,15 @@ module Ci private - def build_builds_for_stage(stage, user, status, trigger_request) - CreateBuildsService.new(self).execute(stage, user, status, trigger_request) + def build_builds_for_stages(stages, user, status, trigger_request) + ## + # Note that `Array#any?` implements a short circuit evaluation, so we + # build builds only for the first stage that has builds available. + # + stages.any? do |stage| + CreateBuildsService.new(self) + .execute(stage, user, status, trigger_request).present? + end end def update_state From 8aed815b6e646df52043867edfdfcf4f618c6a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 10:32:57 +0200 Subject: [PATCH 115/195] Avoid a TypeError when initializing MergeRequest JS class with no arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this sane default you would get the following error when you tried to instantiate a new MergeRequest object with no argument (i.e. `new MergeRequest();`): TypeError: undefined is not an object (evaluating 'this.opts.action') Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/assets/javascripts/merge_request.js.coffee | 2 +- spec/javascripts/merge_request_spec.js.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/merge_request.js.coffee b/app/assets/javascripts/merge_request.js.coffee index 1f46e33142..dabfd91cf1 100644 --- a/app/assets/javascripts/merge_request.js.coffee +++ b/app/assets/javascripts/merge_request.js.coffee @@ -9,7 +9,7 @@ class @MergeRequest # Options: # action - String, current controller action # - constructor: (@opts) -> + constructor: (@opts = {}) -> this.$el = $('.merge-request') this.$('.show-all-commits').on 'click', => diff --git a/spec/javascripts/merge_request_spec.js.coffee b/spec/javascripts/merge_request_spec.js.coffee index 22ebc7039d..3cb67d51c8 100644 --- a/spec/javascripts/merge_request_spec.js.coffee +++ b/spec/javascripts/merge_request_spec.js.coffee @@ -6,7 +6,7 @@ describe 'MergeRequest', -> beforeEach -> fixture.load('merge_requests_show.html') - @merge = new MergeRequest({}) + @merge = new MergeRequest() it 'modifies the Markdown field', -> spyOn(jQuery, 'ajax').and.stub() From 69112072ca915e8d051f39bb8642f1c4fee4b692 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 10:45:08 +0200 Subject: [PATCH 116/195] Add Changelog entry for pipeline status fix --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 2aed8eb322..4defd85ef1 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.9.0 (unreleased) + - Fix pipeline status when there are no builds in pipeline - Fix Error 500 when using closes_issues API with an external issue tracker - Add more information into RSS feed for issues (Alexander Matyushentsev) - Bulk assign/unassign labels to issues. From f30d1fdf94a373649b2b570bbd6d77cbe817ebe0 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:53:10 +0200 Subject: [PATCH 117/195] Add support for Docker Registry manifest v1 --- CHANGELOG | 1 + .../container_registry/_tag.html.haml | 14 ++- lib/container_registry/blob.rb | 2 +- lib/container_registry/client.rb | 4 +- lib/container_registry/tag.rb | 14 ++- .../container_registry/tag_manifest_1.json | 32 ++++++ spec/lib/container_registry/tag_spec.rb | 101 ++++++++++++------ 7 files changed, 130 insertions(+), 38 deletions(-) create mode 100644 spec/fixtures/container_registry/tag_manifest_1.json diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a9..8abefd618d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -29,6 +29,7 @@ v 8.9.0 (unreleased) - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails - Don't fail builds for projects that are deleted + - Support Docker Registry manifest v1 - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/container_registry/_tag.html.haml index 4e9f936539..d5fa07fd18 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/container_registry/_tag.html.haml @@ -9,11 +9,19 @@ - else \- %td - = number_to_human_size(tag.total_size) - · - = pluralize(tag.layers.size, "layer") + - if tag.total_size + = number_to_human_size(tag.total_size) + · + = pluralize(tag.layers.size, "layer") + - else + .light + \- %td + - if tag.created_at = time_ago_in_words(tag.created_at) + - else + .light + \- - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right diff --git a/lib/container_registry/blob.rb b/lib/container_registry/blob.rb index 4e20dc4f87..eb5a259617 100644 --- a/lib/container_registry/blob.rb +++ b/lib/container_registry/blob.rb @@ -18,7 +18,7 @@ module ContainerRegistry end def digest - config['digest'] + config['digest'] || config['blobSum'] end def type diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index 4d726692f4..e0b3f14d38 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -47,7 +47,9 @@ module ContainerRegistry conn.request :json conn.headers['Accept'] = MANIFEST_VERSION - conn.response :json, content_type: /\bjson$/ + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+prettyjws' + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+json' + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v2+json' if options[:user] && options[:password] conn.request(:basic_auth, options[:user].to_s, options[:password].to_s) diff --git a/lib/container_registry/tag.rb b/lib/container_registry/tag.rb index 43f8d6dc8c..7a0929d774 100644 --- a/lib/container_registry/tag.rb +++ b/lib/container_registry/tag.rb @@ -12,6 +12,14 @@ module ContainerRegistry manifest.present? end + def v1? + manifest && manifest['schemaVersion'] == 1 + end + + def v2? + manifest && manifest['schemaVersion'] == 2 + end + def manifest return @manifest if defined?(@manifest) @@ -57,7 +65,9 @@ module ContainerRegistry return @layers if defined?(@layers) return unless manifest - @layers = manifest['layers'].map do |layer| + layers = manifest['layers'] || manifest['fsLayers'] + + @layers = layers.map do |layer| repository.blob(layer) end end @@ -65,7 +75,7 @@ module ContainerRegistry def total_size return unless layers - layers.map(&:size).sum + layers.map(&:size).sum if v2? end def delete diff --git a/spec/fixtures/container_registry/tag_manifest_1.json b/spec/fixtures/container_registry/tag_manifest_1.json new file mode 100644 index 0000000000..d09ede5bea --- /dev/null +++ b/spec/fixtures/container_registry/tag_manifest_1.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "name": "library/alpine", + "tag": "2.6", + "architecture": "amd64", + "fsLayers": [ + { + "blobSum": "sha256:2a3ebcb7fbcc29bf40c4f62863008bb573acdea963454834d9483b3e5300c45d" + } + ], + "history": [ + { + "v1Compatibility": "{\"id\":\"dd807873c9a21bcc82e30317c283e6601d7e19f5cf7867eec34cdd1aeb3f099e\",\"created\":\"2016-01-18T18:32:39.162138276Z\",\"container\":\"556a728876db7b0e621adc029c87c649d32520804f8f15defd67bb070dc1a88d\",\"container_config\":{\"Hostname\":\"556a728876db\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ADD file:7dee8a455bcc39013aa168d27ece9227aad155adbaacbd153d94ca60113f59fc in /\"],\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.3\",\"config\":{\"Hostname\":\"556a728876db\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":4501436}" + } + ], + "signatures": [ + { + "header": { + "jwk": { + "crv": "P-256", + "kid": "4MZL:Z5ZP:2RPA:Q3TD:QOHA:743L:EM2G:QY6Q:ZJCX:BSD7:CRYC:LQ6T", + "kty": "EC", + "x": "qmWOaxPUk7QsE5iTPdeG1e9yNE-wranvQEnWzz9FhWM", + "y": "WeeBpjTOYnTNrfCIxtFY5qMrJNNk9C1vc5ryxbbMD_M" + }, + "alg": "ES256" + }, + "signature": "0zmjTJ4m21yVwAeteLc3SsQ0miScViCDktFPR67W-ozGjjI3iBjlDjwOl6o2sds5ZI9U6bSIKOeLDinGOhHoOQ", + "protected": "eyJmb3JtYXRMZW5ndGgiOjEzNzIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNi0wNi0xNVQxMDo0NDoxNFoifQ" + } + ] +} diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index 858cb0bb13..c7324c2bf7 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -17,46 +17,85 @@ describe ContainerRegistry::Tag do end context 'manifest processing' do - before do - stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). - with(headers: headers). - to_return( - status: 200, - body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'), - headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) - end - - context '#layers' do - subject { tag.layers } - - it { expect(subject.length).to eq(1) } - end - - context '#total_size' do - subject { tag.total_size } - - it { is_expected.to eq(2319870) } - end - - context 'config processing' do + context 'schema v1' do before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). - with(headers: { 'Accept' => 'application/octet-stream' }). + stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + with(headers: headers). to_return( status: 200, - body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')) + body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest_1.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v1+prettyjws' }) end - context '#config' do - subject { tag.config } + context '#layers' do + subject { tag.layers } - it { is_expected.not_to be_nil } + it { expect(subject.length).to eq(1) } end - context '#created_at' do - subject { tag.created_at } + context '#total_size' do + subject { tag.total_size } - it { is_expected.not_to be_nil } + it { is_expected.to be_nil } + end + + context 'config processing' do + context '#config' do + subject { tag.config } + + it { is_expected.to be_nil } + end + + context '#created_at' do + subject { tag.created_at } + + it { is_expected.to be_nil } + end + end + end + + context 'schema v2' do + before do + stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + with(headers: headers). + to_return( + status: 200, + body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) + end + + context '#layers' do + subject { tag.layers } + + it { expect(subject.length).to eq(1) } + end + + context '#total_size' do + subject { tag.total_size } + + it { is_expected.to eq(2319870) } + end + + context 'config processing' do + before do + stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). + with(headers: { 'Accept' => 'application/octet-stream' }). + to_return( + status: 200, + body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')) + end + + context '#config' do + subject { tag.config } + + it { is_expected.not_to be_nil } + end + + context '#created_at' do + subject { tag.created_at } + + it { is_expected.not_to be_nil } + end end end end From 78d5828fb2142c612ceba687debfb97bac2f671e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 13:06:01 +0200 Subject: [PATCH 118/195] Fix typo --- app/services/ci/register_build_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 9583f6c7c4..f0ed09a629 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -8,7 +8,7 @@ module Ci builds = if current_runner.shared? builds. - # don't run projects which have not enables shared runners + # don't run projects which have not enabled shared runners joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). # this returns builds that are ordered by number of running builds From 342434c886a680bea5a4e37dbfbd8d96882ae780 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 12:40:22 +0100 Subject: [PATCH 119/195] Fixed issue with de-selecting dropdown option in issue sidebar Closes #18641 --- .../javascripts/milestone_select.js.coffee | 2 +- app/assets/javascripts/users_select.js.coffee | 2 +- spec/features/issues_spec.rb | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 648e1f3bde..b108f747bd 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -116,7 +116,7 @@ class @MilestoneSelect .val() data = {} data[abilityName] = {} - data[abilityName].milestone_id = selected + data[abilityName].milestone_id = if selected? then selected else null $loading .fadeIn() $dropdown.trigger('loading.gl.dropdown') diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 88246b0feb..3dbc1d7f14 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -31,7 +31,7 @@ class @UsersSelect assignTo = (selected) -> data = {} data[abilityName] = {} - data[abilityName].assignee_id = selected + data[abilityName].assignee_id = if selected? then selected else null $loading .fadeIn() $dropdown.trigger('loading.gl.dropdown') diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index f6fb6a72d2..65fe918e2e 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -396,6 +396,27 @@ describe 'Issues', feature: true do expect(page).to have_content @user.name end end + + it 'allows user to unselect themselves', js: true do + issue2 = create(:issue, project: project, author: @user) + visit namespace_project_issue_path(project.namespace, project, issue2) + + page.within '.assignee' do + click_link 'Edit' + click_link @user.name + + page.within '.value' do + expect(page).to have_content @user.name + end + + click_link 'Edit' + click_link @user.name + + page.within '.value' do + expect(page).to have_content "No assignee" + end + end + end end context 'by unauthorized user' do @@ -440,6 +461,26 @@ describe 'Issues', feature: true do expect(issue.reload.milestone).to be_nil end + + it 'allows user to de-select milestone', js: true do + visit namespace_project_issue_path(project.namespace, project, issue) + + page.within('.milestone') do + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content milestone.title + end + + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content 'None' + end + end + end end context 'by unauthorized user' do From 2d495fce529cc3ac15f7096ddf9962db0fbd1e23 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 14:03:43 +0200 Subject: [PATCH 120/195] Remove reduntant method for building pipeline builds --- app/models/ci/pipeline.rb | 12 +++++------- app/services/ci/create_builds_service.rb | 3 ++- app/services/create_commit_builds_service.rb | 2 +- spec/models/ci/pipeline_spec.rb | 3 +++ spec/services/create_commit_builds_service_spec.rb | 1 + 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 58c6925182..a26cb7dd7e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -91,15 +91,14 @@ module Ci trigger_requests.any? end - def build_builds(user, trigger_request = nil) + def create_builds(user, trigger_request = nil) + ## + # We persist pipeline only if there are builds available + # return unless config_processor build_builds_for_stages(config_processor.stages, user, - 'success', trigger_request) - end - - def create_builds(user, trigger_request = nil) - build_builds(user, trigger_request) && save + 'success', trigger_request) && save end def create_next_builds(build) @@ -121,7 +120,6 @@ module Ci # and save pipeline if we have builds build_builds_for_stages(next_stages, build.user, prior_status, build.trigger_request) && save - end def retried diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index f7f73aff98..b2882b23d3 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -22,7 +22,8 @@ module Ci # don't create the same build twice builds_attrs.reject! do |build_attrs| - @pipeline.builds.find_by(ref: @pipeline.ref, tag: @pipeline.tag, + @pipeline.builds.find_by(ref: @pipeline.ref, + tag: @pipeline.tag, trigger_request: trigger_request, name: build_attrs[:name]) end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 668d0a8654..f947e8f452 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -42,7 +42,7 @@ class CreateCommitBuildsService ## # Skip creating pipeline object if there are no builds for it. # - unless @pipeline.build_builds(user) + unless @pipeline.create_builds(user) @pipeline.errors.add(:base, 'No builds created') return false end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 458013ad9f..34507cf508 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -260,12 +260,15 @@ describe Ci::Pipeline, models: true do end context 'when no builds created' do + let(:pipeline) { build(:ci_pipeline) } + before do stub_ci_pipeline_yaml_file(YAML.dump(before_script: ['ls'])) end it 'returns false' do expect(pipeline.create_builds(nil)).to be_falsey + expect(pipeline).not_to be_persisted end end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 50ce9659c1..deab242f45 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -184,6 +184,7 @@ describe CreateCommitBuildsService, services: true do before: '00000000', after: '31das312', commits: [{ message: 'some msg' }]) + expect(result).to be_falsey expect(Ci::Build.all).to be_empty expect(Ci::Pipeline.count).to eq(0) From d8b399a8c6051a3bdef56e8d7c63ac1d40ddc071 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 15:14:23 +0300 Subject: [PATCH 121/195] Fix project star tooltip in to show actual message. --- app/views/projects/buttons/_star.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 02dbb2985a..71cf5582a4 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,5 +1,5 @@ - if current_user - = link_to toggle_star_namespace_project_path(@project.namespace, @project), class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: "Star project" do + = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do - if current_user.starred?(@project) = icon('star fw') %span.starred Unstar From 13b32e74bcd379eae0422dd971a196d07fa2c5fe Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 15:14:53 +0300 Subject: [PATCH 122/195] Fix project star tooltip on the fly. Introduced new util called updateTooltipTitle. --- app/assets/javascripts/lib/common_utils.js.coffee | 12 ++++++++++++ app/assets/javascripts/star.js.coffee | 2 ++ 2 files changed, 14 insertions(+) diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 0000e99a65..5e3a802f45 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -1,5 +1,8 @@ ((w) -> + window.gl or= {} + window.gl.utils or= {} + jQuery.timefor = (time, suffix, expiredLabel) -> return '' unless time @@ -21,4 +24,13 @@ return timefor + + gl.utils.updateTooltipTitle = ($tooltipEl, newTitle) -> + + $tooltipEl + .tooltip 'destroy' + .attr 'title', newTitle + .tooltip 'fixTitle' + + ) window diff --git a/app/assets/javascripts/star.js.coffee b/app/assets/javascripts/star.js.coffee index f27780dda9..01b28171f7 100644 --- a/app/assets/javascripts/star.js.coffee +++ b/app/assets/javascripts/star.js.coffee @@ -9,9 +9,11 @@ class @Star $this.parent().find('.star-count').text data.star_count if isStarred $starSpan.removeClass('starred').text 'Star' + gl.utils.updateTooltipTitle $this, 'Star project' $starIcon.removeClass('fa-star').addClass 'fa-star-o' else $starSpan.addClass('starred').text 'Unstar' + gl.utils.updateTooltipTitle $this, 'Unstar project' $starIcon.removeClass('fa-star-o').addClass 'fa-star' return From 138ff057a1812ddfbc5ffc4f9406336ca7a3153e Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 15:15:51 +0300 Subject: [PATCH 123/195] Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a9..be9b5315c5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -91,6 +91,7 @@ v 8.9.0 (unreleased) - New custom icons for navigation - Horizontally scrolling navigation on project, group, and profile settings pages - Hide global side navigation by default + - Fix project Star/Unstar project button tooltip - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji From c32e61251e5afa9131f4c5d08f762a6e9f7de110 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer <jacob@gitlab.com> Date: Wed, 15 Jun 2016 14:59:37 +0200 Subject: [PATCH 124/195] Get rid of Gitlab::ShellEnv --- app/services/git_hooks_service.rb | 2 +- lib/gitlab/backend/grack_auth.rb | 7 ------- lib/gitlab/backend/shell_env.rb | 28 ---------------------------- lib/gitlab/gl_id.rb | 11 +++++++++++ lib/gitlab/workhorse.rb | 2 +- 5 files changed, 13 insertions(+), 37 deletions(-) delete mode 100644 lib/gitlab/backend/shell_env.rb create mode 100644 lib/gitlab/gl_id.rb diff --git a/app/services/git_hooks_service.rb b/app/services/git_hooks_service.rb index 8f5c3393df..d7a0c25a04 100644 --- a/app/services/git_hooks_service.rb +++ b/app/services/git_hooks_service.rb @@ -3,7 +3,7 @@ class GitHooksService def execute(user, repo_path, oldrev, newrev, ref) @repo_path = repo_path - @user = Gitlab::ShellEnv.gl_id(user) + @user = Gitlab::GlId.gl_id(user) @oldrev = oldrev @newrev = newrev @ref = ref diff --git a/lib/gitlab/backend/grack_auth.rb b/lib/gitlab/backend/grack_auth.rb index adbf5941a9..7e3f5abba6 100644 --- a/lib/gitlab/backend/grack_auth.rb +++ b/lib/gitlab/backend/grack_auth.rb @@ -1,5 +1,3 @@ -require_relative 'shell_env' - module Grack class AuthSpawner def self.call(env) @@ -61,11 +59,6 @@ module Grack end @user = authenticate_user(login, password) - - if @user - Gitlab::ShellEnv.set_env(@user) - @env['REMOTE_USER'] = @auth.username - end end def ci_request?(login, password) diff --git a/lib/gitlab/backend/shell_env.rb b/lib/gitlab/backend/shell_env.rb deleted file mode 100644 index 9f5adee594..0000000000 --- a/lib/gitlab/backend/shell_env.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Gitlab - # This module provide 2 methods - # to set specific ENV variables for GitLab Shell - module ShellEnv - extend self - - def set_env(user) - # Set GL_ID env variable - if user - ENV['GL_ID'] = gl_id(user) - end - end - - def reset_env - # Reset GL_ID env variable - ENV['GL_ID'] = nil - end - - def gl_id(user) - if user.present? - "user-#{user.id}" - else - # This empty string is used in the render_grack_auth_ok method - "" - end - end - end -end diff --git a/lib/gitlab/gl_id.rb b/lib/gitlab/gl_id.rb new file mode 100644 index 0000000000..624fd00367 --- /dev/null +++ b/lib/gitlab/gl_id.rb @@ -0,0 +1,11 @@ +module Gitlab + module GlId + def self.gl_id(user) + if user.present? + "user-#{user.id}" + else + "" + end + end + end +end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 388f84dbe0..40e8299c36 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -8,7 +8,7 @@ module Gitlab class << self def git_http_ok(repository, user) { - 'GL_ID' => Gitlab::ShellEnv.gl_id(user), + 'GL_ID' => Gitlab::GlId.gl_id(user), 'RepoPath' => repository.path_to_repo, } end From 10ae4a8e71e14053beb9f90196c9450838d8a44e Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 10:20:35 -0500 Subject: [PATCH 125/195] Move admin nav to horizontal layout nav --- app/views/layouts/admin.html.haml | 2 +- app/views/layouts/nav/_admin.html.haml | 23 +++-------------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 6591c52bdb..87064cc9b3 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,5 +1,5 @@ - page_title "Admin Area" - header_title "Admin Area", admin_root_path -- sidebar "admin" +- nav "admin" = render template: "layouts/application" diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index f292730fe4..b2539a1bea 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,93 +1,77 @@ -%ul.nav.nav-sidebar +%ul.nav-links.scrolling-tabs + .fade-left = nav_link(controller: :dashboard, html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview' do - = icon('dashboard fw') %span Overview = nav_link(controller: [:admin, :projects]) do = link_to admin_namespaces_projects_path, title: 'Projects' do - = icon('cube fw') %span Projects = nav_link(controller: :users) do = link_to admin_users_path, title: 'Users' do - = icon('user fw') %span Users = nav_link(controller: :groups) do = link_to admin_groups_path, title: 'Groups' do - = icon('group fw') %span Groups = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do - = icon('key fw') %span Deploy Keys = nav_link path: ['runners#index', 'runners#show'] do = link_to admin_runners_path, title: 'Runners' do - = icon('cog fw') %span Runners %span.count= number_with_delimiter(Ci::Runner.count(:all)) = nav_link path: 'builds#index' do = link_to admin_builds_path, title: 'Builds' do - = icon('link fw') %span Builds %span.count= number_with_delimiter(Ci::Build.count(:all)) = nav_link(controller: :logs) do = link_to admin_logs_path, title: 'Logs' do - = icon('file-text fw') %span Logs = nav_link(controller: :health_check) do = link_to admin_health_check_path, title: 'Health Check' do - = icon('medkit fw') %span Health Check = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do - = icon('bullhorn fw') %span Messages = nav_link(controller: :hooks) do = link_to admin_hooks_path, title: 'Hooks' do - = icon('external-link fw') %span Hooks = nav_link(controller: :background_jobs) do = link_to admin_background_jobs_path, title: 'Background Jobs' do - = icon('cog fw') %span Background Jobs = nav_link(controller: :appearances) do = link_to admin_appearances_path, title: 'Appearances' do - = icon('image') %span Appearance = nav_link(controller: :applications) do = link_to admin_applications_path, title: 'Applications' do - = icon('cloud fw') %span Applications = nav_link(controller: :services) do = link_to admin_application_settings_services_path, title: 'Service Templates' do - = icon('copy fw') %span Service Templates = nav_link(controller: :labels) do = link_to admin_labels_path, title: 'Labels' do - = icon('tags fw') %span Labels = nav_link(controller: :abuse_reports) do = link_to admin_abuse_reports_path, title: "Abuse Reports" do - = icon('exclamation-circle fw') %span Abuse Reports %span.count= number_with_delimiter(AbuseReport.count(:all)) @@ -95,13 +79,12 @@ - if askimet_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path, title: "Spam Logs" do - = icon('exclamation-triangle fw') %span Spam Logs %span.count= number_with_delimiter(SpamLog.count(:all)) = nav_link(controller: :application_settings, html_options: { class: 'separate-item'}) do = link_to admin_application_settings_path, title: 'Settings' do - = icon('cogs fw') %span Settings + .fade-right From 58c8661cd161e10d6dc51300c59850481e61cfd7 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 10:21:09 -0500 Subject: [PATCH 126/195] Remove admin layout-nav counters --- app/views/layouts/nav/_admin.html.haml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index b2539a1bea..6258e6fd54 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -24,12 +24,10 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners - %span.count= number_with_delimiter(Ci::Runner.count(:all)) = nav_link path: 'builds#index' do = link_to admin_builds_path, title: 'Builds' do %span Builds - %span.count= number_with_delimiter(Ci::Build.count(:all)) = nav_link(controller: :logs) do = link_to admin_logs_path, title: 'Logs' do %span @@ -74,14 +72,12 @@ = link_to admin_abuse_reports_path, title: "Abuse Reports" do %span Abuse Reports - %span.count= number_with_delimiter(AbuseReport.count(:all)) - if askimet_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path, title: "Spam Logs" do %span Spam Logs - %span.count= number_with_delimiter(SpamLog.count(:all)) = nav_link(controller: :application_settings, html_options: { class: 'separate-item'}) do = link_to admin_application_settings_path, title: 'Settings' do From 736ba42b249d8ccd22b455f6cd09ae946bd2d855 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 10:26:00 -0500 Subject: [PATCH 127/195] Add counter for abuse reports --- app/views/layouts/nav/_admin.html.haml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 6258e6fd54..1d53f715e8 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -72,6 +72,7 @@ = link_to admin_abuse_reports_path, title: "Abuse Reports" do %span Abuse Reports + %span.badge.count= number_with_delimiter(AbuseReport.count(:all)) - if askimet_enabled? = nav_link(controller: :spam_logs) do From 922a164d60725246ee038d2603d2beed0a82277a Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 15:37:02 -0500 Subject: [PATCH 128/195] Add sub links to overview --- app/views/admin/dashboard/_head.html.haml | 18 ++ app/views/admin/dashboard/index.html.haml | 300 +++++++++++----------- app/views/admin/groups/index.html.haml | 72 +++--- app/views/admin/projects/index.html.haml | 173 +++++++------ app/views/admin/users/index.html.haml | 199 +++++++------- app/views/layouts/nav/_admin.html.haml | 16 +- 6 files changed, 399 insertions(+), 379 deletions(-) create mode 100644 app/views/admin/dashboard/_head.html.haml diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml new file mode 100644 index 0000000000..b1adc316b5 --- /dev/null +++ b/app/views/admin/dashboard/_head.html.haml @@ -0,0 +1,18 @@ +%ul.nav-links.sub-nav + %div{ class: (container_class) } + = nav_link(controller: :dashboard, html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview' do + %span + Overview + = nav_link(controller: [:admin, :projects]) do + = link_to admin_namespaces_projects_path, title: 'Projects' do + %span + Projects + = nav_link(controller: :users) do + = link_to admin_users_path, title: 'Users' do + %span + Users + = nav_link(controller: :groups) do + = link_to admin_groups_path, title: 'Groups' do + %span + Groups diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 6dd2fef395..4682016a88 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,155 +1,159 @@ -.admin-dashboard.prepend-top-default - .row - .col-md-4 - %h4 Statistics - %hr - %p - Forks - %span.light.pull-right - = number_with_delimiter(ForkedProjectLink.count) - %p - Issues - %span.light.pull-right - = number_with_delimiter(Issue.count) - %p - Merge Requests - %span.light.pull-right - = number_with_delimiter(MergeRequest.count) - %p - Notes - %span.light.pull-right - = number_with_delimiter(Note.count) - %p - Snippets - %span.light.pull-right - = number_with_delimiter(Snippet.count) - %p - SSH Keys - %span.light.pull-right - = number_with_delimiter(Key.count) - %p - Milestones - %span.light.pull-right - = number_with_delimiter(Milestone.count) - %p - Active Users - %span.light.pull-right - = number_with_delimiter(User.active.count) - .col-md-4 - %h4 - Features - %hr - %p - Sign up - %span.light.pull-right - = boolean_to_icon signup_enabled? - %p - LDAP - %span.light.pull-right - = boolean_to_icon Gitlab.config.ldap.enabled - %p - Gravatar - %span.light.pull-right - = boolean_to_icon gravatar_enabled? - %p - OmniAuth - %span.light.pull-right - = boolean_to_icon Gitlab.config.omniauth.enabled - %p - Reply by email - %span.light.pull-right - = boolean_to_icon Gitlab::IncomingEmail.enabled? - .col-md-4 - %h4 - Components - - if current_application_settings.version_check_enabled - .pull-right - = version_status_badge +- @no_container = true += render "admin/dashboard/head" - %hr - %p - GitLab - %span.pull-right - = Gitlab::VERSION - %p - GitLab Shell - %span.pull-right - = Gitlab::Shell.new.version - %p - GitLab API - %span.pull-right - = API::API::version - %p - Git - %span.pull-right - = Gitlab::Git.version - %p - Ruby - %span.pull-right - #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} - - %p - Rails - %span.pull-right - #{Rails::VERSION::STRING} - - %p - = Gitlab::Database.adapter_name - %span.pull-right - = Gitlab::Database.version - %hr - .row - .col-sm-4 - .light-well - %h4 Projects - .data - = link_to admin_namespaces_projects_path do - %h1= number_with_delimiter(Project.count) - %hr - = link_to('New Project', new_project_path, class: "btn btn-new") - .col-sm-4 - .light-well - %h4 Users - .data - = link_to admin_users_path do - %h1= number_with_delimiter(User.count) - %hr - = link_to 'New User', new_admin_user_path, class: "btn btn-new" - .col-sm-4 - .light-well - %h4 Groups - .data - = link_to admin_groups_path do - %h1= number_with_delimiter(Group.count) - %hr - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" - - .row.prepend-top-10 - .col-md-4 - %h4 Latest projects - %hr - - @projects.each do |project| +%div{ class: (container_class) } + .admin-dashboard.prepend-top-default + .row + .col-md-4 + %h4 Statistics + %hr %p - = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated' + Forks %span.light.pull-right - #{time_ago_with_tooltip(project.created_at)} - - .col-md-4 - %h4 Latest users - %hr - - @users.each do |user| + = number_with_delimiter(ForkedProjectLink.count) %p - = link_to [:admin, user], class: 'str-truncated' do - = user.name + Issues %span.light.pull-right - #{time_ago_with_tooltip(user.created_at)} - - .col-md-4 - %h4 Latest groups - %hr - - @groups.each do |group| + = number_with_delimiter(Issue.count) %p - = link_to [:admin, group], class: 'str-truncated' do - = group.name + Merge Requests %span.light.pull-right - #{time_ago_with_tooltip(group.created_at)} + = number_with_delimiter(MergeRequest.count) + %p + Notes + %span.light.pull-right + = number_with_delimiter(Note.count) + %p + Snippets + %span.light.pull-right + = number_with_delimiter(Snippet.count) + %p + SSH Keys + %span.light.pull-right + = number_with_delimiter(Key.count) + %p + Milestones + %span.light.pull-right + = number_with_delimiter(Milestone.count) + %p + Active Users + %span.light.pull-right + = number_with_delimiter(User.active.count) + .col-md-4 + %h4 + Features + %hr + %p + Sign up + %span.light.pull-right + = boolean_to_icon signup_enabled? + %p + LDAP + %span.light.pull-right + = boolean_to_icon Gitlab.config.ldap.enabled + %p + Gravatar + %span.light.pull-right + = boolean_to_icon gravatar_enabled? + %p + OmniAuth + %span.light.pull-right + = boolean_to_icon Gitlab.config.omniauth.enabled + %p + Reply by email + %span.light.pull-right + = boolean_to_icon Gitlab::IncomingEmail.enabled? + .col-md-4 + %h4 + Components + - if current_application_settings.version_check_enabled + .pull-right + = version_status_badge + + %hr + %p + GitLab + %span.pull-right + = Gitlab::VERSION + %p + GitLab Shell + %span.pull-right + = Gitlab::Shell.new.version + %p + GitLab API + %span.pull-right + = API::API::version + %p + Git + %span.pull-right + = Gitlab::Git.version + %p + Ruby + %span.pull-right + #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} + + %p + Rails + %span.pull-right + #{Rails::VERSION::STRING} + + %p + = Gitlab::Database.adapter_name + %span.pull-right + = Gitlab::Database.version + %hr + .row + .col-sm-4 + .light-well + %h4 Projects + .data + = link_to admin_namespaces_projects_path do + %h1= number_with_delimiter(Project.count) + %hr + = link_to('New Project', new_project_path, class: "btn btn-new") + .col-sm-4 + .light-well + %h4 Users + .data + = link_to admin_users_path do + %h1= number_with_delimiter(User.count) + %hr + = link_to 'New User', new_admin_user_path, class: "btn btn-new" + .col-sm-4 + .light-well + %h4 Groups + .data + = link_to admin_groups_path do + %h1= number_with_delimiter(Group.count) + %hr + = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + + .row.prepend-top-10 + .col-md-4 + %h4 Latest projects + %hr + - @projects.each do |project| + %p + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated' + %span.light.pull-right + #{time_ago_with_tooltip(project.created_at)} + + .col-md-4 + %h4 Latest users + %hr + - @users.each do |user| + %p + = link_to [:admin, user], class: 'str-truncated' do + = user.name + %span.light.pull-right + #{time_ago_with_tooltip(user.created_at)} + + .col-md-4 + %h4 Latest groups + %hr + - @groups.each do |group| + %p + = link_to [:admin, group], class: 'str-truncated' do + = group.name + %span.light.pull-right + #{time_ago_with_tooltip(group.created_at)} diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 775072a744..4f1996ef7a 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -1,41 +1,45 @@ +- @no_container = true - page_title "Groups" -%h3.page-title - Groups (#{number_with_delimiter(@groups.total_count)}) += render "admin/dashboard/head" -%p.light - Group allows you to keep projects organized. - Use groups for uniting related projects. +%div{ class: (container_class) } + %h3.page-title + Groups (#{number_with_delimiter(@groups.total_count)}) -.top-area - .nav-search - = form_tag admin_groups_path, method: :get, class: 'form-inline' do - = hidden_field_tag :sort, @sort - = text_field_tag :name, params[:name], class: "form-control" - = button_tag "Search", class: "btn submit btn-primary" + %p.light + Group allows you to keep projects organized. + Use groups for uniting related projects. - .nav-controls - .dropdown.inline - %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - %b.caret - %ul.dropdown-menu - %li - = link_to admin_groups_path(sort: sort_value_recently_created) do + .top-area + .nav-search + = form_tag admin_groups_path, method: :get, class: 'form-inline' do + = hidden_field_tag :sort, @sort + = text_field_tag :name, params[:name], class: "form-control" + = button_tag "Search", class: "btn submit btn-primary" + + .nav-controls + .dropdown.inline + %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_recently_created - = link_to admin_groups_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to admin_groups_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to admin_groups_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + %b.caret + %ul.dropdown-menu + %li + = link_to admin_groups_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to admin_groups_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + = link_to admin_groups_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to admin_groups_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + = link_to 'New Group', new_admin_group_path, class: "btn btn-new" -%ul.content-list - - @groups.each do |group| - = render 'group', group: group + %ul.content-list + - @groups.each do |group| + = render 'group', group: group -= paginate @groups, theme: "gitlab" + = paginate @groups, theme: "gitlab" diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index aa07afa0d6..4822cb693c 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,94 +1,97 @@ +- @no_container = true - page_title "Projects" = render 'shared/show_aside' += render "admin/dashboard/head" -.row.prepend-top-default - %aside.col-md-3 - .panel.admin-filter - = form_tag admin_namespaces_projects_path, method: :get, class: '' do - .form-group - = label_tag :name, 'Name:' - = text_field_tag :name, params[:name], class: "form-control" +%div{ class: (container_class) } + .row.prepend-top-default + %aside.col-md-3 + .panel.admin-filter + = form_tag admin_namespaces_projects_path, method: :get, class: '' do + .form-group + = label_tag :name, 'Name:' + = text_field_tag :name, params[:name], class: "form-control" - .form-group - = label_tag :namespace_id, "Namespace" - = namespace_select_tag :namespace_id, selected: params[:namespace_id], class: 'input-large' + .form-group + = label_tag :namespace_id, "Namespace" + = namespace_select_tag :namespace_id, selected: params[:namespace_id], class: 'input-large' - .form-group - %strong Activity - .checkbox - = label_tag :with_push do - = check_box_tag :with_push, 1, params[:with_push] - %span Projects with push events - .checkbox - = label_tag :abandoned do - = check_box_tag :abandoned, 1, params[:abandoned] - %span No activity over 6 month - .checkbox - = label_tag :with_archived do - = check_box_tag :with_archived, 1, params[:with_archived] - %span Show archived projects + .form-group + %strong Activity + .checkbox + = label_tag :with_push do + = check_box_tag :with_push, 1, params[:with_push] + %span Projects with push events + .checkbox + = label_tag :abandoned do + = check_box_tag :abandoned, 1, params[:abandoned] + %span No activity over 6 month + .checkbox + = label_tag :with_archived do + = check_box_tag :with_archived, 1, params[:with_archived] + %span Show archived projects - %fieldset - %strong Visibility level: - .visibility-levels - - Project.visibility_levels.each do |label, level| - .checkbox - %label - = check_box_tag 'visibility_levels[]', level, params[:visibility_levels].present? && params[:visibility_levels].include?(level.to_s) - %span.descr - = visibility_level_icon(level) - = label - %fieldset - %strong Problems - .checkbox - = label_tag :last_repository_check_failed do - = check_box_tag :last_repository_check_failed, 1, params[:last_repository_check_failed] - %span Last repository check failed + %fieldset + %strong Visibility level: + .visibility-levels + - Project.visibility_levels.each do |label, level| + .checkbox + %label + = check_box_tag 'visibility_levels[]', level, params[:visibility_levels].present? && params[:visibility_levels].include?(level.to_s) + %span.descr + = visibility_level_icon(level) + = label + %fieldset + %strong Problems + .checkbox + = label_tag :last_repository_check_failed do + = check_box_tag :last_repository_check_failed, 1, params[:last_repository_check_failed] + %span Last repository check failed - = hidden_field_tag :sort, params[:sort] - = button_tag "Search", class: "btn submit btn-primary" - = link_to "Reset", admin_namespaces_projects_path, class: "btn btn-cancel" + = hidden_field_tag :sort, params[:sort] + = button_tag "Search", class: "btn submit btn-primary" + = link_to "Reset", admin_namespaces_projects_path, class: "btn btn-cancel" - %section.col-md-9 - .panel.panel-default - .panel-heading - Projects (#{@projects.total_count}) - .controls - .dropdown.inline - %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - %b.caret - %ul.dropdown-menu - %li - = link_to admin_namespaces_projects_path(sort: sort_value_recently_created) do + %section.col-md-9 + .panel.panel-default + .panel-heading + Projects (#{@projects.total_count}) + .controls + .dropdown.inline + %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_recently_created - = link_to admin_namespaces_projects_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to admin_namespaces_projects_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to admin_namespaces_projects_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated - = link_to admin_namespaces_projects_path(sort: sort_value_largest_repo) do - = sort_title_largest_repo - = link_to 'New Project', new_project_path, class: "btn btn-sm btn-success" - %ul.well-list - - @projects.each do |project| - %li - .list-item-name - %span{ class: visibility_level_color(project.visibility_level) } - = visibility_level_icon(project.visibility_level) - = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] - .pull-right - - if project.archived - %span.label.label-warning archived - %span.label.label-gray - = repository_size(project) - = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" - = link_to 'Destroy', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-sm btn-remove" - - if @projects.blank? - .nothing-here-block 0 projects matches - = paginate @projects, theme: "gitlab" + %b.caret + %ul.dropdown-menu + %li + = link_to admin_namespaces_projects_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to admin_namespaces_projects_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + = link_to admin_namespaces_projects_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to admin_namespaces_projects_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + = link_to admin_namespaces_projects_path(sort: sort_value_largest_repo) do + = sort_title_largest_repo + = link_to 'New Project', new_project_path, class: "btn btn-sm btn-success" + %ul.well-list + - @projects.each do |project| + %li + .list-item-name + %span{ class: visibility_level_color(project.visibility_level) } + = visibility_level_icon(project.visibility_level) + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + .pull-right + - if project.archived + %span.label.label-warning archived + %span.label.label-gray + = repository_size(project) + = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" + = link_to 'Destroy', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-sm btn-remove" + - if @projects.blank? + .nothing-here-block 0 projects matches + = paginate @projects, theme: "gitlab" diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index d6743081c8..d0a696da64 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,107 +1,110 @@ +- @no_container = true - page_title "Users" = render 'shared/show_aside' += render "admin/dashboard/head" -.admin-filter - %ul.nav-links - %li{class: "#{'active' unless params[:filter]}"} - = link_to admin_users_path do - Active - %small.badge= number_with_delimiter(User.active.count) - %li{class: "#{'active' if params[:filter] == "admins"}"} - = link_to admin_users_path(filter: "admins") do - Admins - %small.badge= number_with_delimiter(User.admins.count) - %li.filter-two-factor-enabled{class: "#{'active' if params[:filter] == 'two_factor_enabled'}"} - = link_to admin_users_path(filter: 'two_factor_enabled') do - 2FA Enabled - %small.badge= number_with_delimiter(User.with_two_factor.count) - %li.filter-two-factor-disabled{class: "#{'active' if params[:filter] == 'two_factor_disabled'}"} - = link_to admin_users_path(filter: 'two_factor_disabled') do - 2FA Disabled - %small.badge= number_with_delimiter(User.without_two_factor.count) - %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} - = link_to admin_users_path(filter: 'external') do - External - %small.badge= number_with_delimiter(User.external.count) - %li{class: "#{'active' if params[:filter] == "blocked"}"} - = link_to admin_users_path(filter: "blocked") do - Blocked - %small.badge= number_with_delimiter(User.blocked.count) - %li{class: "#{'active' if params[:filter] == "wop"}"} - = link_to admin_users_path(filter: "wop") do - Without projects - %small.badge= number_with_delimiter(User.without_projects.count) +%div{ class: (container_class) } + .admin-filter + %ul.nav-links + %li{class: "#{'active' unless params[:filter]}"} + = link_to admin_users_path do + Active + %small.badge= number_with_delimiter(User.active.count) + %li{class: "#{'active' if params[:filter] == "admins"}"} + = link_to admin_users_path(filter: "admins") do + Admins + %small.badge= number_with_delimiter(User.admins.count) + %li.filter-two-factor-enabled{class: "#{'active' if params[:filter] == 'two_factor_enabled'}"} + = link_to admin_users_path(filter: 'two_factor_enabled') do + 2FA Enabled + %small.badge= number_with_delimiter(User.with_two_factor.count) + %li.filter-two-factor-disabled{class: "#{'active' if params[:filter] == 'two_factor_disabled'}"} + = link_to admin_users_path(filter: 'two_factor_disabled') do + 2FA Disabled + %small.badge= number_with_delimiter(User.without_two_factor.count) + %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} + = link_to admin_users_path(filter: 'external') do + External + %small.badge= number_with_delimiter(User.external.count) + %li{class: "#{'active' if params[:filter] == "blocked"}"} + = link_to admin_users_path(filter: "blocked") do + Blocked + %small.badge= number_with_delimiter(User.blocked.count) + %li{class: "#{'active' if params[:filter] == "wop"}"} + = link_to admin_users_path(filter: "wop") do + Without projects + %small.badge= number_with_delimiter(User.without_projects.count) - .row-content-block.second-block - .pull-right - .dropdown.inline - %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_name - %b.caret - %ul.dropdown-menu - %li - = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + .row-content-block.second-block + .pull-right + .dropdown.inline + %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_name - = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do - = sort_title_recently_signin - = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do - = sort_title_oldest_signin - = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do - = sort_title_recently_created - = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do - = sort_title_oldest_created - = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do - = sort_title_recently_updated - = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do - = sort_title_oldest_updated + %b.caret + %ul.dropdown-menu + %li + = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + = sort_title_name + = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do + = sort_title_recently_signin + = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do + = sort_title_oldest_signin + = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do + = sort_title_recently_created + = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do + = sort_title_oldest_created + = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do + = sort_title_recently_updated + = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do + = sort_title_oldest_updated - = link_to 'New User', new_admin_user_path, class: "btn btn-new" - = form_tag admin_users_path, method: :get, class: 'form-inline' do - .form-group - = search_field_tag :name, params[:name], placeholder: 'Name, email or username', class: 'form-control', spellcheck: false - = hidden_field_tag "filter", params[:filter] - = button_tag class: 'btn btn-primary' do - %i.fa.fa-search + = link_to 'New User', new_admin_user_path, class: "btn btn-new" + = form_tag admin_users_path, method: :get, class: 'form-inline' do + .form-group + = search_field_tag :name, params[:name], placeholder: 'Name, email or username', class: 'form-control', spellcheck: false + = hidden_field_tag "filter", params[:filter] + = button_tag class: 'btn btn-primary' do + %i.fa.fa-search -.panel.panel-default - %ul.well-list - - @users.each do |user| - %li - .list-item-name - - if user.blocked? - = icon("lock", class: "cred") - - else - = icon("user", class: "cgreen") - = link_to user.name, [:admin, user] - - if user.admin? - %strong.cred (Admin) - - if user.external? - %strong.cred (External) - - if user == current_user - %span.cred It's you! - .pull-right - %span.light - %i.fa.fa-envelope - = mail_to user.email, user.email, class: 'light' -   + .panel.panel-default + %ul.well-list + - @users.each do |user| + %li + .list-item-name + - if user.blocked? + = icon("lock", class: "cred") + - else + = icon("user", class: "cgreen") + = link_to user.name, [:admin, user] + - if user.admin? + %strong.cred (Admin) + - if user.external? + %strong.cred (External) + - if user == current_user + %span.cred It's you! .pull-right - = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn-grouped btn btn-xs' - - unless user == current_user - - if user.ldap_blocked? - = link_to '#', title: 'Cannot unblock LDAP blocked users', data: {toggle: 'tooltip'}, class: 'btn-grouped btn btn-xs btn-success disabled' do - %i.fa.fa-lock - Unblock - - elsif user.blocked? - = link_to 'Unblock', unblock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success' - - else - = link_to 'Block', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: 'btn-grouped btn btn-xs btn-warning' - - if user.access_locked? - = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } - - if user.can_be_removed? - = link_to 'Destroy', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Maybe block the user instead? Are you sure?" }, method: :delete, class: 'btn-grouped btn btn-xs btn-remove' -= paginate @users, theme: "gitlab" + %span.light + %i.fa.fa-envelope + = mail_to user.email, user.email, class: 'light' +   + .pull-right + = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn-grouped btn btn-xs' + - unless user == current_user + - if user.ldap_blocked? + = link_to '#', title: 'Cannot unblock LDAP blocked users', data: {toggle: 'tooltip'}, class: 'btn-grouped btn btn-xs btn-success disabled' do + %i.fa.fa-lock + Unblock + - elsif user.blocked? + = link_to 'Unblock', unblock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success' + - else + = link_to 'Block', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: 'btn-grouped btn btn-xs btn-warning' + - if user.access_locked? + = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } + - if user.can_be_removed? + = link_to 'Destroy', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Maybe block the user instead? Are you sure?" }, method: :delete, class: 'btn-grouped btn btn-xs btn-remove' + = paginate @users, theme: "gitlab" diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 1d53f715e8..9d85ec1d6d 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,21 +1,9 @@ %ul.nav-links.scrolling-tabs .fade-left - = nav_link(controller: :dashboard, html_options: {class: 'home'}) do - = link_to admin_root_path, title: 'Overview' do + = nav_link(controller: %w(dashboard admin projects users groups), html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview - = nav_link(controller: [:admin, :projects]) do - = link_to admin_namespaces_projects_path, title: 'Projects' do - %span - Projects - = nav_link(controller: :users) do - = link_to admin_users_path, title: 'Users' do - %span - Users - = nav_link(controller: :groups) do - = link_to admin_groups_path, title: 'Groups' do - %span - Groups = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do %span From d1c3f3d87258336b6ad50639d4f63647e95958df Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 20:44:51 -0500 Subject: [PATCH 129/195] Add monitoring link with subtabs --- .../admin/background_jobs/_head.html.haml | 14 +++ .../admin/background_jobs/show.html.haml | 82 ++++++++-------- app/views/admin/health_check/show.html.haml | 93 ++++++++++--------- app/views/admin/logs/show.html.haml | 52 ++++++----- app/views/layouts/nav/_admin.html.haml | 15 +-- 5 files changed, 137 insertions(+), 119 deletions(-) create mode 100644 app/views/admin/background_jobs/_head.html.haml diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml new file mode 100644 index 0000000000..ce7693bcad --- /dev/null +++ b/app/views/admin/background_jobs/_head.html.haml @@ -0,0 +1,14 @@ +%ul.nav-links.sub-nav + %div{ class: (container_class) } + = nav_link(controller: :background_jobs) do + = link_to admin_background_jobs_path, title: 'Background Jobs' do + %span + Background Jobs + = nav_link(controller: :logs) do + = link_to admin_logs_path, title: 'Logs' do + %span + Logs + = nav_link(controller: :health_check) do + = link_to admin_health_check_path, title: 'Health Check' do + %span + Health Check diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index de5bc050cf..654d261aa9 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,46 +1,50 @@ +- @no_container = true - page_title "Background Jobs" -%h3.page-title Background Jobs -%p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing += render 'admin/background_jobs/head' -%hr +%div{ class: (container_class) } + %h3.page-title Background Jobs + %p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing -.panel.panel-default - .panel-heading Sidekiq running processes - .panel-body - - if @sidekiq_processes.empty? - %h4.cred - %i.fa.fa-exclamation-triangle - There are no running sidekiq processes. Please restart GitLab - - else - .table-holder - %table.table - %thead - %th USER - %th PID - %th CPU - %th MEM - %th STATE - %th START - %th COMMAND - %tbody - - @sidekiq_processes.each do |process| - - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) - - data = process.strip.split(' ') - %tr - %td= gitlab_config.user - - 5.times do - %td= data.shift - %td= data.join(' ') + %hr - .clearfix - %p - %i.fa.fa-exclamation-circle - If '[25 of 25 busy]' is shown, restart GitLab with 'sudo service gitlab reload'. - %p - %i.fa.fa-exclamation-circle - If more than one sidekiq process is listed, stop GitLab, kill the remaining sidekiq processes (sudo pkill -u #{gitlab_config.user} -f sidekiq) and restart GitLab. + .panel.panel-default + .panel-heading Sidekiq running processes + .panel-body + - if @sidekiq_processes.empty? + %h4.cred + %i.fa.fa-exclamation-triangle + There are no running sidekiq processes. Please restart GitLab + - else + .table-holder + %table.table + %thead + %th USER + %th PID + %th CPU + %th MEM + %th STATE + %th START + %th COMMAND + %tbody + - @sidekiq_processes.each do |process| + - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) + - data = process.strip.split(' ') + %tr + %td= gitlab_config.user + - 5.times do + %td= data.shift + %td= data.join(' ') + + .clearfix + %p + %i.fa.fa-exclamation-circle + If '[25 of 25 busy]' is shown, restart GitLab with 'sudo service gitlab reload'. + %p + %i.fa.fa-exclamation-circle + If more than one sidekiq process is listed, stop GitLab, kill the remaining sidekiq processes (sudo pkill -u #{gitlab_config.user} -f sidekiq) and restart GitLab. -.panel.panel-default - %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} + .panel.panel-default + %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index c2313986a7..7b8407f915 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -1,49 +1,52 @@ +- @no_container = true - page_title "Health Check" += render 'admin/background_jobs/head' -%h3.page-title - Health Check -.bs-callout.clearfix - .pull-left - %p - Access token is - %code#health-check-token= current_application_settings.health_check_access_token - = button_to reset_health_check_token_admin_application_settings_path, - method: :put, class: 'btn btn-default', - data: { confirm: 'Are you sure you want to reset the health check token?' } do - = icon('refresh') - Reset health check access token -%p.light - Health information can be retrieved as plain text, JSON, or XML using: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) +%div{ class: (container_class) } + %h3.page-title + Health Check + .bs-callout.clearfix + .pull-left + %p + Access token is + %code#health-check-token= current_application_settings.health_check_access_token + = button_to reset_health_check_token_admin_application_settings_path, + method: :put, class: 'btn btn-default', + data: { confirm: 'Are you sure you want to reset the health check token?' } do + = icon('refresh') + Reset health check access token + %p.light + Health information can be retrieved as plain text, JSON, or XML using: + %ul + %li + %code= health_check_url(token: current_application_settings.health_check_access_token) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) -%p.light - You can also ask for the status of specific services: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) + %p.light + You can also ask for the status of specific services: + %ul + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) -%hr -.panel.panel-default - .panel-heading - Current Status: - - if @errors.blank? - = icon('circle', class: 'cgreen') - Healthy - - else - = icon('warning', class: 'cred') - Unhealthy - .panel-body - - if @errors.blank? - No Health Problems Detected - - else - = @errors + %hr + .panel.panel-default + .panel-heading + Current Status: + - if @errors.blank? + = icon('circle', class: 'cgreen') + Healthy + - else + = icon('warning', class: 'cred') + Unhealthy + .panel-body + - if @errors.blank? + No Health Problems Detected + - else + = @errors diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml index 698feb571a..5ddc3b9ea8 100644 --- a/app/views/admin/logs/show.html.haml +++ b/app/views/admin/logs/show.html.haml @@ -1,28 +1,32 @@ +- @no_container = true - page_title "Logs" - loggers = [Gitlab::GitLogger, Gitlab::AppLogger, Gitlab::ProductionLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger] -%ul.nav-links.log-tabs - - loggers.each do |klass| - %li{ class: (klass == Gitlab::GitLogger ? 'active' : '') } - = link_to klass::file_name, "##{klass::file_name_noext}", - 'data-toggle' => 'tab' -.row-content-block - To prevent performance issues admin logs output the last 2000 lines -.tab-content - - loggers.each do |klass| - .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), - id: klass::file_name_noext } - .file-holder#README - .file-title - %i.fa.fa-file - = klass::file_name - .pull-right - = link_to '#', class: 'log-bottom' do - %i.fa.fa-arrow-down - Scroll down - .file-content.logs - %ol - - klass.read_latest.each do |line| - %li - %p= line += render 'admin/background_jobs/head' + +%div{ class: (container_class) } + %ul.nav-links.log-tabs + - loggers.each do |klass| + %li{ class: (klass == Gitlab::GitLogger ? 'active' : '') } + = link_to klass::file_name, "##{klass::file_name_noext}", + 'data-toggle' => 'tab' + .row-content-block + To prevent performance issues admin logs output the last 2000 lines + .tab-content + - loggers.each do |klass| + .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), + id: klass::file_name_noext } + .file-holder#README + .file-title + %i.fa.fa-file + = klass::file_name + .pull-right + = link_to '#', class: 'log-bottom' do + %i.fa.fa-arrow-down + Scroll down + .file-content.logs + %ol + - klass.read_latest.each do |line| + %li + %p= line diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 9d85ec1d6d..ad25d4908f 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -16,14 +16,10 @@ = link_to admin_builds_path, title: 'Builds' do %span Builds - = nav_link(controller: :logs) do - = link_to admin_logs_path, title: 'Logs' do + = nav_link(controller: %w(background_jobs logs health_check)) do + = link_to admin_background_jobs_path, title: 'Monitoring' do %span - Logs - = nav_link(controller: :health_check) do - = link_to admin_health_check_path, title: 'Health Check' do - %span - Health Check + Monitoring = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do %span @@ -32,10 +28,7 @@ = link_to admin_hooks_path, title: 'Hooks' do %span Hooks - = nav_link(controller: :background_jobs) do - = link_to admin_background_jobs_path, title: 'Background Jobs' do - %span - Background Jobs + = nav_link(controller: :appearances) do = link_to admin_appearances_path, title: 'Appearances' do %span From d3b6c18526e72d43cd20db5bb2c69c60320197ce Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 08:07:06 -0500 Subject: [PATCH 130/195] Move builds tab to admin overview --- app/views/admin/builds/index.html.haml | 89 ++++++++++++----------- app/views/admin/dashboard/_head.html.haml | 4 + app/views/layouts/nav/_admin.html.haml | 6 +- 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index d74cf8598e..efd5b12cfe 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/builds/index.html.haml @@ -1,49 +1,54 @@ -.top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to admin_builds_path do - All - %span.badge.js-totalbuilds-count= @all_builds.count(:id) +- @no_container = true += render "admin/dashboard/head" - %li{class: ('active' if @scope == 'running')} - = link_to admin_builds_path(scope: :running) do - Running - %span.badge.js-running-count= number_with_delimiter(@all_builds.running_or_pending.count(:id)) +%div{ class: (container_class) } - %li{class: ('active' if @scope == 'finished')} - = link_to admin_builds_path(scope: :finished) do - Finished - %span.badge.js-running-count= number_with_delimiter(@all_builds.finished.count(:id)) + .top-area + %ul.nav-links + %li{class: ('active' if @scope.nil?)} + = link_to admin_builds_path do + All + %span.badge.js-totalbuilds-count= @all_builds.count(:id) - .nav-controls - - if @all_builds.running_or_pending.any? - = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + %li{class: ('active' if @scope == 'running')} + = link_to admin_builds_path(scope: :running) do + Running + %span.badge.js-running-count= number_with_delimiter(@all_builds.running_or_pending.count(:id)) -.row-content-block.second-block - #{(@scope || 'all').capitalize} builds + %li{class: ('active' if @scope == 'finished')} + = link_to admin_builds_path(scope: :finished) do + Finished + %span.badge.js-running-count= number_with_delimiter(@all_builds.finished.count(:id)) -%ul.content-list - - if @builds.blank? - %li - .nothing-here-block No builds to show - - else - .table-holder - %table.table.builds - %thead - %tr - %th Status - %th Build ID - %th Project - %th Commit - %th Ref - %th Runner - %th Name - %th Tags - %th Duration - %th Finished at - %th + .nav-controls + - if @all_builds.running_or_pending.any? + = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - - @builds.each do |build| - = render "admin/builds/build", build: build + .row-content-block.second-block + #{(@scope || 'all').capitalize} builds - = paginate @builds, theme: 'gitlab' + %ul.content-list + - if @builds.blank? + %li + .nothing-here-block No builds to show + - else + .table-holder + %table.table.builds + %thead + %tr + %th Status + %th Build ID + %th Project + %th Commit + %th Ref + %th Runner + %th Name + %th Tags + %th Duration + %th Finished at + %th + + - @builds.each do |build| + = render "admin/builds/build", build: build + + = paginate @builds, theme: 'gitlab' diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index b1adc316b5..ef9d246b2a 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -4,6 +4,10 @@ = link_to admin_root_path, title: 'Overview' do %span Overview + = nav_link path: 'builds#index' do + = link_to admin_builds_path, title: 'Builds' do + %span + Builds = nav_link(controller: [:admin, :projects]) do = link_to admin_namespaces_projects_path, title: 'Projects' do %span diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index ad25d4908f..a72f101713 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,6 +1,6 @@ %ul.nav-links.scrolling-tabs .fade-left - = nav_link(controller: %w(dashboard admin projects users groups), html_options: {class: 'home'}) do + = nav_link(controller: %w(dashboard admin projects users groups builds), html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview @@ -12,10 +12,6 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners - = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Builds' do - %span - Builds = nav_link(controller: %w(background_jobs logs health_check)) do = link_to admin_background_jobs_path, title: 'Monitoring' do %span From f1245bde894a04fcc83771281d3051a87c5cdab2 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 09:11:17 -0500 Subject: [PATCH 131/195] Nest li elements directly under ul --- app/views/admin/background_jobs/_head.html.haml | 4 ++-- app/views/admin/dashboard/_head.html.haml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml index ce7693bcad..d78682532e 100644 --- a/app/views/admin/background_jobs/_head.html.haml +++ b/app/views/admin/background_jobs/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } = nav_link(controller: :background_jobs) do = link_to admin_background_jobs_path, title: 'Background Jobs' do %span diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index ef9d246b2a..617db25a7a 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } = nav_link(controller: :dashboard, html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview' do %span From 3213023dd657ba6c5c6d690fae2ca44a409b16fd Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 16:16:21 +0200 Subject: [PATCH 132/195] Show created_at in table column --- app/views/projects/container_registry/_tag.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/container_registry/_tag.html.haml index d5fa07fd18..f35faa6afb 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/container_registry/_tag.html.haml @@ -17,11 +17,11 @@ .light \- %td - - if tag.created_at - = time_ago_in_words(tag.created_at) - - else - .light - \- + - if tag.created_at + = time_ago_in_words(tag.created_at) + - else + .light + \- - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right From d5efd17d8ae540f2e85b81ae787779c3871d42c4 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Wed, 15 Jun 2016 17:36:14 +0300 Subject: [PATCH 133/195] Fix admin active tab tests Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- features/admin/active_tab.feature | 22 +++++++++++----- features/steps/admin/active_tab.rb | 40 ++++++++++++++---------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/features/admin/active_tab.feature b/features/admin/active_tab.feature index 5de07e90e2..f5bb06dea7 100644 --- a/features/admin/active_tab.feature +++ b/features/admin/active_tab.feature @@ -5,28 +5,36 @@ Feature: Admin Active Tab Scenario: On Admin Home Given I visit admin page - Then the active main tab should be Home + Then the active main tab should be Overview And no other main tabs should be active Scenario: On Admin Projects Given I visit admin projects page - Then the active main tab should be Projects + Then the active main tab should be Overview + And the active sub tab should be Projects And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Groups Given I visit admin groups page - Then the active main tab should be Groups + Then the active main tab should be Overview + And the active sub tab should be Groups And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Users Given I visit admin users page - Then the active main tab should be Users + Then the active main tab should be Overview + And the active sub tab should be Users And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Logs Given I visit admin logs page - Then the active main tab should be Logs + Then the active main tab should be Monitoring + And the active sub tab should be Logs And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Messages Given I visit admin messages page @@ -40,5 +48,7 @@ Feature: Admin Active Tab Scenario: On Admin Resque Given I visit admin Resque page - Then the active main tab should be Resque + Then the active main tab should be Monitoring + And the active sub tab should be Resque And no other main tabs should be active + And no other sub tabs should be active diff --git a/features/steps/admin/active_tab.rb b/features/steps/admin/active_tab.rb index f2db180138..9b1689a819 100644 --- a/features/steps/admin/active_tab.rb +++ b/features/steps/admin/active_tab.rb @@ -1,45 +1,41 @@ class Spinach::Features::AdminActiveTab < Spinach::FeatureSteps include SharedAuthentication include SharedPaths - include SharedSidebarActiveTab + include SharedActiveTab - step 'the active main tab should be Home' do + step 'the active main tab should be Overview' do ensure_active_main_tab('Overview') end - step 'the active main tab should be Projects' do - ensure_active_main_tab('Projects') + step 'the active sub tab should be Projects' do + ensure_active_sub_tab('Projects') end - step 'the active main tab should be Groups' do - ensure_active_main_tab('Groups') + step 'the active sub tab should be Groups' do + ensure_active_sub_tab('Groups') end - step 'the active main tab should be Users' do - ensure_active_main_tab('Users') - end - - step 'the active main tab should be Logs' do - ensure_active_main_tab('Logs') + step 'the active sub tab should be Users' do + ensure_active_sub_tab('Users') end step 'the active main tab should be Hooks' do ensure_active_main_tab('Hooks') end - step 'the active main tab should be Resque' do - ensure_active_main_tab('Background Jobs') + step 'the active main tab should be Monitoring' do + ensure_active_main_tab('Monitoring') + end + + step 'the active sub tab should be Resque' do + ensure_active_sub_tab('Background Jobs') + end + + step 'the active sub tab should be Logs' do + ensure_active_sub_tab('Logs') end step 'the active main tab should be Messages' do ensure_active_main_tab('Messages') end - - step 'no other main tabs should be active' do - expect(page).to have_selector('.nav-sidebar > li.active', count: 1) - end - - def ensure_active_main_tab(content) - expect(find('.nav-sidebar > li.active')).to have_content(content) - end end From fefc3e9e4f476078e0402dd2585c664beda4b98f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 16:48:42 +0200 Subject: [PATCH 134/195] Make sure that we test RegisterBuildService behavior for deleted projects --- .../ci/register_build_service_spec.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_build_service_spec.rb index fa4c2fddeb..f28f2f1438 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_build_service_spec.rb @@ -45,6 +45,28 @@ module Ci end end + context 'deleted projects' do + before do + project.update(pending_delete: true) + end + + context 'for shared runners' do + before do + project.update(shared_runners_enabled: true) + end + + it 'does not pick a build' do + expect(service.execute(shared_runner)).to be_nil + end + end + + context 'for specific runner' do + it 'does not pick a build' do + expect(service.execute(specific_runner)).to be_nil + end + end + end + context 'allow shared runners' do before do project.update(shared_runners_enabled: true) From 6d10d8251c1acfe59a6fa92e4ff9c780dfbb2a0d Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 09:51:18 -0500 Subject: [PATCH 135/195] Rearrange order of tabs --- app/views/admin/dashboard/_head.html.haml | 8 ++++---- app/views/layouts/nav/_admin.html.haml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 617db25a7a..7b3f88c24d 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -4,10 +4,6 @@ = link_to admin_root_path, title: 'Overview' do %span Overview - = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Builds' do - %span - Builds = nav_link(controller: [:admin, :projects]) do = link_to admin_namespaces_projects_path, title: 'Projects' do %span @@ -20,3 +16,7 @@ = link_to admin_groups_path, title: 'Groups' do %span Groups + = nav_link path: 'builds#index' do + = link_to admin_builds_path, title: 'Builds' do + %span + Builds diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index a72f101713..54aa34bee0 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -4,6 +4,10 @@ = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview + = nav_link(controller: %w(background_jobs logs health_check)) do + = link_to admin_background_jobs_path, title: 'Monitoring' do + %span + Monitoring = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do %span @@ -12,10 +16,6 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners - = nav_link(controller: %w(background_jobs logs health_check)) do - = link_to admin_background_jobs_path, title: 'Monitoring' do - %span - Monitoring = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do %span From b4ed272da9a466ffc003d2918bc24c173a4d43ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 16:51:11 +0200 Subject: [PATCH 136/195] Add index on `requested_at` to the `members` table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- ...0160615142710_add_index_on_requested_at_to_members.rb | 9 +++++++++ db/schema.rb | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20160615142710_add_index_on_requested_at_to_members.rb diff --git a/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb new file mode 100644 index 0000000000..63f7392e54 --- /dev/null +++ b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb @@ -0,0 +1,9 @@ +class AddIndexOnRequestedAtToMembers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def change + add_concurrent_index :members, :requested_at + end +end diff --git a/db/schema.rb b/db/schema.rb index e148a3c975..6a3be7297e 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: 20160610301627) do +ActiveRecord::Schema.define(version: 20160615142710) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -572,6 +572,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree add_index "members", ["created_at", "id"], name: "index_members_on_created_at_and_id", using: :btree add_index "members", ["invite_token"], name: "index_members_on_invite_token", unique: true, using: :btree + add_index "members", ["requested_at"], name: "index_members_on_requested_at", using: :btree add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree add_index "members", ["type"], name: "index_members_on_type", using: :btree add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree From 56ca4859552cc23d5fee88f056952535034e99c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 13:42:46 +0200 Subject: [PATCH 137/195] Fix wrong partial path in JS view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/views/groups/group_members/update.js.haml | 2 +- app/views/projects/project_members/update.js.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml index b0b3a51ce5..da71de4cd1 100644 --- a/app/views/groups/group_members/update.js.haml +++ b/app/views/groups/group_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member))}'); + $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @group_member))}'); diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml index 2fb3a41d54..45f8ef8906 100644 --- a/app/views/projects/project_members/update.js.haml +++ b/app/views/projects/project_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render("project_member", member: @project_member))}'); + $("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @project_member))}'); From 0cb7d834f7c428bce4341aef55ac35285cb0071c Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Tue, 14 Jun 2016 11:48:42 -0500 Subject: [PATCH 138/195] Add handler icon to prioritized labels --- app/assets/javascripts/LabelManager.js.coffee | 7 +++++-- app/assets/stylesheets/framework/lists.scss | 2 +- app/assets/stylesheets/framework/nav.scss | 6 ++++++ app/assets/stylesheets/pages/labels.scss | 14 ++++++++++++++ app/views/projects/labels/index.html.haml | 8 ++++---- app/views/shared/_label_row.html.haml | 2 ++ 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/LabelManager.js.coffee b/app/assets/javascripts/LabelManager.js.coffee index 365a062bb8..b06bcf0fcb 100644 --- a/app/assets/javascripts/LabelManager.js.coffee +++ b/app/assets/javascripts/LabelManager.js.coffee @@ -42,10 +42,10 @@ class @LabelManager $from = @prioritizedLabels if $from.find('li').length is 1 - $from.find('.empty-message').show() + $from.find('.empty-message').removeClass('hidden') if not $target.find('li').length - $target.find('.empty-message').hide() + $target.find('.empty-message').addClass('hidden') $label.detach().appendTo($target) @@ -54,6 +54,9 @@ class @LabelManager if action is 'remove' xhr = $.ajax url: url, type: 'DELETE' + + # Restore empty message + $from.find('.empty-message').removeClass('hidden') unless $from.find('li').length else xhr = @savePrioritySort($label, action) diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index b34ec16cdb..a12c0bba44 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -159,7 +159,7 @@ ul.content-list { background-color: $gray-light; border: dotted 1px $gray-dark; margin: 1px 0; - min-height: 30px; + min-height: 52px; } } } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 829222509f..7b856db236 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -242,6 +242,12 @@ } } } + + &.adjust { + .nav-text, .nav-controls { + width: auto; + } + } } .layout-nav { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index bc65404a74..046c38aba4 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -115,6 +115,13 @@ } } +.draggable-handler { + display: inline-block; + opacity: 0; + transition: opacity .3s; + color: $gray-darkest; +} + .prioritized-labels { margin-bottom: 30px; @@ -122,6 +129,13 @@ display: none; color: $gray-light; } + + li:hover { + .draggable-handler { + display: inline-block; + opacity: 1; + } + } } .other-labels { diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 6e1baa46b0..aa4d69550e 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -4,9 +4,10 @@ = render "projects/issues/head" %div{ class: (container_class) } - .top-area + .top-area.adjust .nav-text - Labels can be applied to issues and merge requests. + Labels can be applied to issues and merge requests. Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging. + .nav-controls - if can?(current_user, :admin_label, @project) = link_to new_namespace_project_label_path(@project.namespace, @project), class: "btn btn-new" do @@ -19,10 +20,9 @@ .prioritized-labels{ class: ('hide' if hide) } %h5 Prioritized Labels %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_namespace_project_labels_path(@project.namespace, @project) } + %p.empty-message{ class: ('hidden' unless @prioritized_labels.empty?) } No prioritized labels yet - if @prioritized_labels.present? = render @prioritized_labels - - else - %p.empty-message No prioritized labels yet .other-labels - if can?(current_user, :admin_label, @project) %h5{ class: ('hide' if hide) } Other Labels diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 478c04318c..77676454b5 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -1,5 +1,7 @@ %span.label-row - if can?(current_user, :admin_label, @project) + .draggable-handler + = icon('bars') .js-toggle-priority.toggle-priority{ data: { url: remove_priority_namespace_project_label_path(@project.namespace, @project, label), dom_id: dom_id(label) } } %button.add-priority.btn.has-tooltip{ title: 'Prioritize', :'data-placement' => 'top' } From 415b032ba1d003bb407581ce3069c95ac178bfd4 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 00:15:46 +0300 Subject: [PATCH 139/195] Prevent default disabled buttons and links. --- CHANGELOG | 1 + app/assets/javascripts/application.js.coffee | 1 + .../javascripts/lib/common_utils.js.coffee | 7 +++++ spec/javascripts/application_spec.js.coffee | 30 +++++++++++++++++++ .../fixtures/application.html.haml | 2 ++ 5 files changed, 41 insertions(+) create mode 100644 spec/javascripts/application_spec.js.coffee create mode 100644 spec/javascripts/fixtures/application.html.haml diff --git a/CHANGELOG b/CHANGELOG index a215d79467..bb5bde9b08 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -77,6 +77,7 @@ v 8.9.0 (unreleased) - RepositoryCheck::SingleRepositoryWorker public and private methods are now instrumented - Improve issuables APIs performance when accessing notes !4471 - External links now open in a new tab + - Prevent default actions of disabled buttons and links - Markdown editor now correctly resets the input value on edit cancellation !4175 - Toggling a task list item in a issue/mr description does not creates a Todo for mentions - Improved UX of date pickers on issue & milestone forms diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd..6c16f89cef 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -125,6 +125,7 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + gl.utils.preventDisabledButtons() bootstrapBreakpoint = bp.getBreakpointSize() $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 5e3a802f45..4f1779b848 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -32,5 +32,12 @@ .attr 'title', newTitle .tooltip 'fixTitle' + gl.utils.preventDisabledButtons = -> + + $('.btn').click (e) -> + if $(this).hasClass 'disabled' + e.preventDefault() + e.stopImmediatePropagation() + return false ) window diff --git a/spec/javascripts/application_spec.js.coffee b/spec/javascripts/application_spec.js.coffee new file mode 100644 index 0000000000..8af39c41f2 --- /dev/null +++ b/spec/javascripts/application_spec.js.coffee @@ -0,0 +1,30 @@ +#= require lib/common_utils + +describe 'Application', -> + describe 'disable buttons', -> + fixture.preload('application.html') + + beforeEach -> + fixture.load('application.html') + + it 'should prevent default action for disabled buttons', -> + + gl.utils.preventDisabledButtons() + + isClicked = false + $button = $ '#test-button' + + $button.click -> isClicked = true + $button.trigger 'click' + + expect(isClicked).toBe false + + + it 'should be on the same page if a disabled link clicked', -> + + locationBeforeLinkClick = window.location.href + gl.utils.preventDisabledButtons() + + $('#test-link').click() + + expect(window.location.href).toBe locationBeforeLinkClick diff --git a/spec/javascripts/fixtures/application.html.haml b/spec/javascripts/fixtures/application.html.haml new file mode 100644 index 0000000000..3fc6114407 --- /dev/null +++ b/spec/javascripts/fixtures/application.html.haml @@ -0,0 +1,2 @@ +%a#test-link.btn.disabled{:href => "/foo"} Test link +%button#test-button.btn.disabled Test Button From bcbe9b4de8776dbbaee6e374200de395cae3c61a Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Wed, 15 Jun 2016 18:47:43 +0300 Subject: [PATCH 140/195] Fix admin hooks spec Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- spec/features/admin/admin_hooks_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index 7265cdac7a..31633817d5 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -12,9 +12,11 @@ describe "Admin::Hooks", feature: true do describe "GET /admin/hooks" do it "should be ok" do visit admin_root_path - page.within ".sidebar-wrapper" do + + page.within ".layout-nav" do click_on "Hooks" end + expect(current_path).to eq(admin_hooks_path) end From c279f40bc4347f487c4ae3f401e3f8287a0161ca Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Wed, 15 Jun 2016 18:51:15 +0300 Subject: [PATCH 141/195] Add bottom padding for merge request command line text Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- app/views/projects/merge_requests/_show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index c4df8bd504..01887f1002 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -37,7 +37,7 @@ = render "projects/merge_requests/widget/show.html.haml" - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) - .light.prepend-top-default + .light.prepend-top-default.append-bottom-default You can also accept this merge request manually using the = succeed '.' do = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" From b21980bff48de425a3994cb3914650d06d48e486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 17:25:48 +0200 Subject: [PATCH 142/195] Fix permission checks in member row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/helpers/members_helper.rb | 6 ------ app/views/shared/members/_member.html.haml | 5 +++-- spec/helpers/members_helper_spec.rb | 16 ---------------- 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index a53828ef4e..877c77050b 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -6,12 +6,6 @@ module MembersHelper "#{action}_#{member.type.underscore}".to_sym end - def can_see_member_roles?(source:, user: nil) - return false unless user - - user.is_admin? || source.members.exists?(user_id: user.id) - end - def remove_member_message(member, user: nil) user = current_user if defined?(current_user) diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index c69d4cbfbe..0191814849 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -1,4 +1,5 @@ -- show_roles = local_assigns.fetch(:show_roles, true) +- default_show_roles = can?(current_user, action_member_permission(:update, member), member) || can?(current_user, action_member_permission(:destroy, member), member) +- show_roles = local_assigns.fetch(:show_roles, default_show_roles) - show_controls = local_assigns.fetch(:show_controls, true) - user = member.user @@ -36,7 +37,7 @@ method: :post, class: 'btn-xs btn' - - if show_roles && can_see_member_roles?(source: member.source, user: current_user) + - if show_roles %span.pull-right %strong= member.human_access - if show_controls diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index 0b1a76156e..7998209b7b 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -9,22 +9,6 @@ describe MembersHelper do it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } end - describe '#can_see_member_roles?' do - let(:project) { create(:empty_project) } - let(:group) { create(:group) } - let(:user) { build(:user) } - let(:admin) { build(:user, :admin) } - let(:project_member) { create(:project_member, project: project) } - let(:group_member) { create(:group_member, group: group) } - - it { expect(can_see_member_roles?(source: project, user: nil)).to be_falsy } - it { expect(can_see_member_roles?(source: group, user: nil)).to be_falsy } - it { expect(can_see_member_roles?(source: project, user: admin)).to be_truthy } - it { expect(can_see_member_roles?(source: group, user: admin)).to be_truthy } - it { expect(can_see_member_roles?(source: project, user: project_member.user)).to be_truthy } - it { expect(can_see_member_roles?(source: group, user: group_member.user)).to be_truthy } - end - describe '#remove_member_message' do let(:requester) { build(:user) } let(:project) { create(:project) } From e3529d543225dac3867ba7273cb9b3275c7a097f Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 17:23:49 +0100 Subject: [PATCH 143/195] Pinned sidebar navigation option Closes #18542 --- app/assets/javascripts/application.js.coffee | 26 +++- app/assets/javascripts/sidebar.js.coffee | 8 +- .../stylesheets/framework/gitlab-theme.scss | 7 +- app/assets/stylesheets/framework/header.scss | 5 - app/assets/stylesheets/framework/sidebar.scss | 121 ++++++++++-------- app/helpers/nav_helper.rb | 17 ++- app/views/layouts/_collapse_button.html.haml | 7 +- app/views/layouts/_page.html.haml | 7 +- app/views/layouts/header/_default.html.haml | 2 +- 9 files changed, 124 insertions(+), 76 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd..030ef3a60b 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -127,7 +127,7 @@ window.onload = -> $ -> bootstrapBreakpoint = bp.getBreakpointSize() - $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") + $(".nav-sidebar").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") # Click a .js-select-on-focus field, select the contents $(".js-select-on-focus").on "focusin", -> @@ -257,3 +257,27 @@ $ -> gl.awardsHandler = new AwardsHandler() checkInitialSidebarSize() new Aside() + + # Sidenav pinning + if bootstrapBreakpoint isnt 'lg' and $.cookie('pin_nav') is 'true' + $.cookie('pin_nav', 'false') + $('.page-with-sidebar') + .toggleClass('page-sidebar-collapsed page-sidebar-expanded') + .removeClass('page-sidebar-pinned') + $('.navbar-fixed-top').removeClass('header-pinned-nav') + + $(document) + .off 'click', '.js-nav-pin' + .on 'click', '.js-nav-pin', (e) -> + e.preventDefault() + + $(this).toggleClass 'is-active' + + if $.cookie('pin_nav') is 'true' + $.cookie 'pin_nav', 'false' + $('.page-with-sidebar').removeClass('page-sidebar-pinned') + $('.navbar-fixed-top').removeClass('header-pinned-nav') + else + $.cookie 'pin_nav', 'true' + $('.page-with-sidebar').addClass('page-sidebar-pinned') + $('.navbar-fixed-top').addClass('header-pinned-nav') diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index 2ce63c1642..e7471893d2 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -3,10 +3,14 @@ expanded = 'page-sidebar-expanded' toggleSidebar = -> $('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}") - $('header').toggleClass("header-collapsed header-expanded") + $('.navbar-fixed-top').toggleClass("header-collapsed header-expanded") + + if $.cookie('pin_nav') is 'true' + $('.navbar-fixed-top').toggleClass('header-pinned-nav') + $('.page-with-sidebar').toggleClass('page-sidebar-pinned') setTimeout ( -> - niceScrollBars = $('.nicescroll').niceScroll(); + niceScrollBars = $('.nav-sidebar').niceScroll(); niceScrollBars.updateScrollBar(); ), 300 diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 408d4a68e1..bb09de4121 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -8,14 +8,9 @@ */ @mixin gitlab-theme($color-light, $color, $color-darker, $color-dark) { .page-with-sidebar { - - .collapse-nav a { + .collapse-nav { color: $color-light; background: $color; - - &:hover { - color: $white-light; - } } .sidebar-wrapper { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 63996ea44f..595b541379 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -79,14 +79,9 @@ header { &.header-collapsed { padding: 0 16px; - - .side-nav-toggle { - display: block; - } } .side-nav-toggle { - display: none; position: absolute; left: -10px; margin: 6px 0; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 4668e7e911..64b2725abf 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -6,8 +6,6 @@ position: fixed; top: 0; bottom: 0; - overflow-y: auto; - overflow-x: hidden; left: 0; height: 100%; transition-duration: .3s; @@ -17,6 +15,11 @@ .sidebar-wrapper { z-index: 1000; background: $background-color; + + .nicescroll-rails-hr { + // TODO: Figure out why nicescroll doesn't hide horizontal bar + display: none!important; + } } .content-wrapper { @@ -34,22 +37,19 @@ } } -.sidebar-wrapper { +.sidebar-user { + padding: 15px; + position: absolute; + left: 0; + bottom: 0; + width: $sidebar_width; + overflow: hidden; + transition-duration: .3s; - .sidebar-user { - padding: 15px 22px; - position: fixed; - bottom: 0; - width: $sidebar_width; - overflow: hidden; - transition-duration: .3s; - - .username { - margin-left: 10px; - width: $sidebar_width - 2 * 10px; - font-size: 16px; - line-height: 34px; - } + .username { + margin-left: 10px; + font-size: 16px; + line-height: 36px; } } @@ -65,19 +65,19 @@ .nav-sidebar { - margin-top: 22 + $header-height; - margin-bottom: 116px; + position: absolute; + top: 50px; + bottom: 65px; + width: 100%; transition-duration: .3s; - list-style: none; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; &.navbar-collapse { padding: 0 !important; } li { - width: $sidebar_width; - &.separate-item { padding-top: 10px; margin-top: 10px; @@ -90,14 +90,14 @@ } a { - width: $sidebar_width; - padding: 7px 15px 7px 23px; + padding: 7px 15px 7px 12px; font-size: $gl-font-size; line-height: 24px; display: block; text-decoration: none; font-weight: normal; outline: none; + white-space: nowrap; &:hover { text-decoration: none; @@ -138,28 +138,47 @@ } } -.collapse-nav a { - width: $sidebar_width; - position: fixed; +.collapse-nav { + width: 100%; + position: absolute;; top: 0; left: 0; padding: 5px 0; font-size: 18px; background: transparent; - height: 50px; - text-align: center; - line-height: 40px; - transition-duration: .3s; - outline: none; +} - &:hover { +.nav-header-btn { + padding: 10px 5px; + color: inherit; + transition-duration: .3s; + + &:hover, + &:focus { + color: $white-light; text-decoration: none; } } -.sidebar-wrapper { - &.hidden-nav { - width: 0; +.toggle-nav-collapse { + position: relative; + left: 10px; + line-height: 40px; +} + +.pin-nav-btn { + position: absolute; + right: 10px; + top: 2px; + + .fa { + transition: transform .15s; + } + + &.is-active { + .fa { + transform: rotate(90deg); + } } } @@ -204,27 +223,23 @@ } .page-sidebar-expanded { - - @media (max-width: $screen-sm-max) { - padding-left: 0; - } - .sidebar-wrapper { width: $sidebar_width; + } +} - .nav-sidebar { - width: $sidebar_width; +.page-sidebar-pinned { + .content-wrapper, + .layout-nav { + @media (min-width: $screen-lg-min) { + padding-left: $sidebar_width; } + } +} - .nav-sidebar li a { - width: $sidebar_width; - - &.back-link { - i { - opacity: 0; - } - } - } +header.header-pinned-nav { + @media (min-width: $screen-lg-min) { + padding-left: ($sidebar_width + $gl-padding); } } diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 469accf314..d53ee3c45d 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -12,10 +12,10 @@ module NavHelper end def page_sidebar_class - if nav_menu_collapsed? - "page-sidebar-collapsed" + if pinned_nav? + "page-sidebar-expanded page-sidebar-pinned" else - "page-sidebar-expanded" + "page-sidebar-collapsed" end end @@ -37,6 +37,13 @@ module NavHelper def nav_header_class class_name = " with-horizontal-nav" if defined?(nav) && nav + + if pinned_nav? + class_name << " header-expanded header-pinned-nav" + else + class_name << " header-collapsed" + end + class_name end @@ -47,4 +54,8 @@ module NavHelper def nav_control_class "nav-control" if current_user end + + def pinned_nav? + cookies[:pin_nav] == 'true' + end end diff --git a/app/views/layouts/_collapse_button.html.haml b/app/views/layouts/_collapse_button.html.haml index e4fab89737..5442ee4efe 100644 --- a/app/views/layouts/_collapse_button.html.haml +++ b/app/views/layouts/_collapse_button.html.haml @@ -1 +1,6 @@ -= link_to icon('bars'), '#', class: 'toggle-nav-collapse', title: "Open/Close" += link_to '#', class: 'nav-header-btn toggle-nav-collapse', title: "Open/Close" do + %span.sr-only Toggle navigation + = icon('bars') += link_to '#', class: "nav-header-btn pin-nav-btn #{'is-active' if pinned_nav?} visible-lg-block js-nav-pin", title: 'Pin/Unpin navigation' do + %span.sr-only Toggle navigation pinning + = icon('thumb-tack') diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f89e858279..90e872c461 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,6 +1,7 @@ -.page-with-sidebar.page-sidebar-collapsed{ class: "#{page_gutter_class}" } +.page-with-sidebar{ class: "#{page_sidebar_class} #{page_gutter_class}" } .sidebar-wrapper.nicescroll{ class: nav_sidebar_class } - + %header.collapse-nav + = render partial: 'layouts/collapse_button' - if defined?(sidebar) && sidebar = render "layouts/nav/#{sidebar}" - elsif current_user @@ -8,8 +9,6 @@ - else = render 'layouts/nav/explore' - .collapse-nav - = render partial: 'layouts/collapse_button' - if current_user = link_to current_user, class: 'sidebar-user', title: "Profile", data: {user: current_user.username} do = image_tag avatar_icon(current_user, 60), alt: 'Profile', class: 'avatar avatar s36' diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ef31520f5c..40a2c81eeb 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,4 @@ -%header.navbar.navbar-fixed-top.navbar-gitlab.header-collapsed{ class: nav_header_class } +%header.navbar.navbar-fixed-top.navbar-gitlab{ class: nav_header_class } %div{ class: fluid_layout ? "container-fluid" : "container-fluid" } .header-content %button.side-nav-toggle{type: 'button'} From 8a9164bf04fe20bfee9ea7923c655f4600e88c7f Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 8 Jun 2016 18:10:46 +0200 Subject: [PATCH 144/195] Set inverse_of for Project/Services relation This ensures that code such as this don't run needless SQL queries: project.gitlab_issue_tracker_service.project This also means that if the root `project` eager loads any associations the Service object will be able to re-use those. --- CHANGELOG | 1 + app/models/project.rb | 2 +- app/models/service.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bb5bde9b08..b767996bc8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -99,6 +99,7 @@ v 8.9.0 (unreleased) - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed + - Set inverse_of for Project/Service association to reduce the number of queries v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/models/project.rb b/app/models/project.rb index fdbc84474e..0bb815e64e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -81,7 +81,7 @@ class Project < ActiveRecord::Base has_one :jira_service, dependent: :destroy has_one :redmine_service, dependent: :destroy has_one :custom_issue_tracker_service, dependent: :destroy - has_one :gitlab_issue_tracker_service, dependent: :destroy + has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project has_one :external_wiki_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" diff --git a/app/models/service.rb b/app/models/service.rb index bf35239750..40d39933ad 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -18,7 +18,7 @@ class Service < ActiveRecord::Base after_commit :reset_updated_properties after_commit :cache_project_has_external_issue_tracker - belongs_to :project + belongs_to :project, inverse_of: :services has_one :service_hook validates :project_id, presence: true, unless: Proc.new { |service| service.template? } From 9d74eb462298dc553bdaae81cd6476d6c5a1952c Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 18:14:20 +0100 Subject: [PATCH 145/195] Increased speed of sidebar transition --- app/assets/javascripts/application.js.coffee | 8 +- app/assets/stylesheets/framework/header.scss | 16 +--- app/assets/stylesheets/framework/nav.scss | 2 +- app/assets/stylesheets/framework/sidebar.scss | 83 +++++-------------- .../stylesheets/framework/variables.scss | 1 + 5 files changed, 30 insertions(+), 80 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 030ef3a60b..704911aa13 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -275,8 +275,12 @@ $ -> if $.cookie('pin_nav') is 'true' $.cookie 'pin_nav', 'false' - $('.page-with-sidebar').removeClass('page-sidebar-pinned') - $('.navbar-fixed-top').removeClass('header-pinned-nav') + $('.page-with-sidebar') + .removeClass('page-sidebar-pinned') + .toggleClass('page-sidebar-collapsed page-sidebar-expanded') + $('.navbar-fixed-top') + .removeClass('header-pinned-nav') + .toggleClass('header-collapsed header-expanded') else $.cookie 'pin_nav', 'true' $('.page-with-sidebar').addClass('page-sidebar-pinned') diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 595b541379..dca4dbb9f7 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -3,7 +3,7 @@ * */ header { - transition-duration: .3s; + transition: padding $sidebar-transition-duration; &.navbar-empty { height: $header-height; @@ -103,9 +103,7 @@ header { .header-content { position: relative; height: $header-height; - padding-right: 40px; padding-left: 30px; - transition-duration: .3s; @media (min-width: $screen-sm-min) { padding-right: 0; @@ -193,18 +191,6 @@ header { } } -.header-collapsed { - margin-left: 0; - - .header-content { - - @media (min-width: $screen-sm-max) { - padding-left: 30px; - transition-duration: .3s; - } - } -} - .tanuki-shape { transition: all 0.8s; diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 829222509f..c1a860b0d7 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -251,7 +251,7 @@ z-index: 11; background: $background-color; border-bottom: 1px solid $border-color; - transition-duration: .3s; + transition: padding $sidebar-transition-duration; text-align: center; .container-fluid { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 64b2725abf..1ac11989d7 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -1,6 +1,6 @@ .page-with-sidebar { padding-top: $header-height; - transition-duration: .3s; + transition: padding $sidebar-transition-duration; .sidebar-wrapper { position: fixed; @@ -8,7 +8,8 @@ bottom: 0; left: 0; height: 100%; - transition-duration: .3s; + overflow: hidden; + transition: width $sidebar-transition-duration; } } @@ -24,6 +25,7 @@ .content-wrapper { width: 100%; + transition: padding $sidebar-transition-duration; .container-fluid { background: #fff; @@ -44,13 +46,9 @@ bottom: 0; width: $sidebar_width; overflow: hidden; - transition-duration: .3s; - - .username { - margin-left: 10px; - font-size: 16px; - line-height: 36px; - } + font-size: 16px; + line-height: 36px; + transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; } @@ -68,8 +66,7 @@ position: absolute; top: 50px; bottom: 65px; - width: 100%; - transition-duration: .3s; + width: $sidebar_width; overflow-y: auto; overflow-x: hidden; @@ -99,11 +96,9 @@ outline: none; white-space: nowrap; - &:hover { - text-decoration: none; - } - - &:active, &:focus { + &:hover, + &:active, + &:focus { text-decoration: none; } @@ -115,10 +110,6 @@ svg { margin-right: 13px; } - - &.back-link i { - transition-duration: .3s; - } } } @@ -129,20 +120,12 @@ } } -.sidebar-subnav { - margin-left: 0; - padding-left: 0; - - li { - list-style: none; - } -} - .collapse-nav { width: 100%; - position: absolute;; + position: absolute; top: 0; left: 0; + min-height: 50px; padding: 5px 0; font-size: 18px; background: transparent; @@ -187,38 +170,6 @@ .sidebar-wrapper { width: 0; - - .nav-sidebar { - width: 0; - - li { - width: auto; - - a { - span { - display: none; - } - } - } - } - - .collapse-nav a { - width: 0; - - i { - display: none; - } - } - - .sidebar-user { - width: 0; - padding-left: 0; - padding-right: 0; - - .username { - display: none; - } - } } } @@ -240,6 +191,14 @@ header.header-pinned-nav { @media (min-width: $screen-lg-min) { padding-left: ($sidebar_width + $gl-padding); + + .side-nav-toggle { + display: none; + } + + .header-content { + padding-left: 0; + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 752d8ec878..670edb9300 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -6,6 +6,7 @@ $sidebar_width: 220px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 258px; +$sidebar-transition-duration: .15s; /* * UI elements From 3fe4a2f525375a353755e0620988c33c85cd9f9e Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg <zegerjan@gitlab.com> Date: Thu, 2 Jun 2016 19:36:10 +0000 Subject: [PATCH 146/195] Fix race condition on auto merge --- CHANGELOG | 1 + .../projects/merge_requests_controller.rb | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bb5bde9b08..7d34937a06 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -99,6 +99,7 @@ v 8.9.0 (unreleased) - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed + - Fix race condition on merge when build succeeds v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 67e7187c10..49b1f3cec3 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -205,9 +205,18 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.update(merge_error: nil) if params[:merge_when_build_succeeds].present? && @merge_request.pipeline && @merge_request.pipeline.active? - MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) - .execute(@merge_request) - @status = :merge_when_build_succeeds + if @merge_request.ci_commit.active? + MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) + .execute(@merge_request) + @status = :merge_when_build_succeeds + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + elsif @merge_request.ci_commit.success? + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + @status = :success + else + @status = :failed + end else MergeWorker.perform_async(@merge_request.id, current_user.id, params) @status = :success From 17ad286e5db45c2d0d39fdceb8f201fe2e780a25 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Thu, 9 Jun 2016 11:54:48 +0200 Subject: [PATCH 147/195] Rename ci_commit to pipeline --- CHANGELOG | 2 +- app/controllers/projects/merge_requests_controller.rb | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7d34937a06..15adf75847 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -42,6 +42,7 @@ v 8.9.0 (unreleased) - Add DB index on users.state - Add rake task 'gitlab:db:configure' for conditionally seeding or migrating the database - Changed the Slack build message to use the singular duration if necessary (Aran Koning) + - Fix race condition on merge when build succeeds - Links from a wiki page to other wiki pages should be rewritten as expected - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos) - Fix issues filter when ordering by milestone @@ -99,7 +100,6 @@ v 8.9.0 (unreleased) - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed - - Fix race condition on merge when build succeeds v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 49b1f3cec3..851822d805 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -204,14 +204,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.update(merge_error: nil) - if params[:merge_when_build_succeeds].present? && @merge_request.pipeline && @merge_request.pipeline.active? - if @merge_request.ci_commit.active? + if params[:merge_when_build_succeeds].present? + if @merge_request.pipeline && @merge_request.pipeline.active? MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) .execute(@merge_request) @status = :merge_when_build_succeeds - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - elsif @merge_request.ci_commit.success? + elsif @merge_request.pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time MergeWorker.perform_async(@merge_request.id, current_user.id, params) @status = :success else From 7d9157ff47c1380492a64aa3c7a1e1a7fa6b8e37 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 18:33:18 +0100 Subject: [PATCH 148/195] Clicking body closes nav Fixed issue when nav wasn't present --- app/assets/javascripts/sidebar.js.coffee | 16 ++++++++++++++++ app/helpers/nav_helper.rb | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index e7471893d2..68009e5864 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -14,6 +14,22 @@ toggleSidebar = -> niceScrollBars.updateScrollBar(); ), 300 +$(document) + .off 'click', 'body' + .on 'click', 'body', (e) -> + unless $.cookie('pin_nav') is 'true' + $target = $(e.target) + $nav = $target.closest('.sidebar-wrapper') + pageExpanded = $('.page-with-sidebar').hasClass('page-sidebar-expanded') + $toggle = $target.closest('.toggle-nav-collapse, .side-nav-toggle') + + if $nav.length is 0 and pageExpanded and $toggle.length is 0 + $('.page-with-sidebar') + .toggleClass('page-sidebar-collapsed page-sidebar-expanded') + + $('.navbar-fixed-top') + .toggleClass('header-collapsed header-expanded') + $(document).on("click", '.toggle-nav-collapse, .side-nav-toggle', (e) -> e.preventDefault() diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index d53ee3c45d..3ff8be5e28 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -36,7 +36,8 @@ module NavHelper end def nav_header_class - class_name = " with-horizontal-nav" if defined?(nav) && nav + class_name = '' + class_name << " with-horizontal-nav" if defined?(nav) && nav if pinned_nav? class_name << " header-expanded header-pinned-nav" From 6064bccaed7a1d5f54daf221982453f4140047df Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 11:38:47 -0600 Subject: [PATCH 149/195] Hide the Todo button in the collapsed issuable sidebar. --- app/assets/stylesheets/pages/issuable.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f57845ad9c..2a1f0d1d87 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -248,11 +248,16 @@ padding-bottom: 0; margin-bottom: 10px; } + + .issuable-header-btn { + display: none; + } } .issuable-header-btn { background: $gray-normal; border: 1px solid $border-gray-normal; + &:hover { background: $gray-dark; border: 1px solid $border-gray-dark; From 7886692147ac76e749729505ab368782e76f174e Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 19:35:37 +0100 Subject: [PATCH 150/195] Moved pinned button to the bottom Changed breakpoint width to 1440px --- app/assets/javascripts/application.js.coffee | 2 +- .../stylesheets/framework/gitlab-theme.scss | 7 +++- app/assets/stylesheets/framework/sidebar.scss | 36 ++++++++++++------- .../stylesheets/framework/variables.scss | 1 + app/views/layouts/_collapse_button.html.haml | 5 +-- app/views/layouts/_page.html.haml | 6 ++-- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 704911aa13..bd835436a0 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -259,7 +259,7 @@ $ -> new Aside() # Sidenav pinning - if bootstrapBreakpoint isnt 'lg' and $.cookie('pin_nav') is 'true' + if $(window).width() < 1440 and $.cookie('pin_nav') is 'true' $.cookie('pin_nav', 'false') $('.page-with-sidebar') .toggleClass('page-sidebar-collapsed page-sidebar-expanded') diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index bb09de4121..0a8603b670 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -8,9 +8,14 @@ */ @mixin gitlab-theme($color-light, $color, $color-darker, $color-dark) { .page-with-sidebar { - .collapse-nav { + .toggle-nav-collapse, + .pin-nav-btn { color: $color-light; background: $color; + + &:hover { + color: $white-light; + } } .sidebar-wrapper { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 1ac11989d7..281c0a0e1e 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -49,6 +49,10 @@ font-size: 16px; line-height: 36px; transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; + + @media (min-width: $sidebar-breakpoint) { + bottom: 50px; + } } @@ -70,6 +74,10 @@ overflow-y: auto; overflow-x: hidden; + @media (min-width: $sidebar-breakpoint) { + bottom: 115px; + } + &.navbar-collapse { padding: 0 !important; } @@ -120,15 +128,15 @@ } } -.collapse-nav { - width: 100%; +.toggle-nav-collapse { + width: $sidebar_width; position: absolute; top: 0; left: 0; min-height: 50px; padding: 5px 0; font-size: 18px; - background: transparent; + line-height: 30px; } .nav-header-btn { @@ -143,16 +151,18 @@ } } -.toggle-nav-collapse { - position: relative; - left: 10px; - line-height: 40px; -} - .pin-nav-btn { + display: none; position: absolute; - right: 10px; - top: 2px; + left: 0; + bottom: 0; + height: 50px; + width: $sidebar_width; + line-height: 30px; + + @media (min-width: $sidebar-breakpoint) { + display: block; + } .fa { transition: transform .15s; @@ -182,14 +192,14 @@ .page-sidebar-pinned { .content-wrapper, .layout-nav { - @media (min-width: $screen-lg-min) { + @media (min-width: $sidebar-breakpoint) { padding-left: $sidebar_width; } } } header.header-pinned-nav { - @media (min-width: $screen-lg-min) { + @media (min-width: $sidebar-breakpoint) { padding-left: ($sidebar_width + $gl-padding); .side-nav-toggle { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 670edb9300..acada1f16a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -7,6 +7,7 @@ $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 258px; $sidebar-transition-duration: .15s; +$sidebar-breakpoint: 1440px; /* * UI elements diff --git a/app/views/layouts/_collapse_button.html.haml b/app/views/layouts/_collapse_button.html.haml index 5442ee4efe..8c140a5943 100644 --- a/app/views/layouts/_collapse_button.html.haml +++ b/app/views/layouts/_collapse_button.html.haml @@ -1,6 +1,3 @@ -= link_to '#', class: 'nav-header-btn toggle-nav-collapse', title: "Open/Close" do += link_to '#', class: 'nav-header-btn text-center toggle-nav-collapse', title: "Open/Close" do %span.sr-only Toggle navigation = icon('bars') -= link_to '#', class: "nav-header-btn pin-nav-btn #{'is-active' if pinned_nav?} visible-lg-block js-nav-pin", title: 'Pin/Unpin navigation' do - %span.sr-only Toggle navigation pinning - = icon('thumb-tack') diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 90e872c461..199ab3c38c 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,7 +1,6 @@ .page-with-sidebar{ class: "#{page_sidebar_class} #{page_gutter_class}" } .sidebar-wrapper.nicescroll{ class: nav_sidebar_class } - %header.collapse-nav - = render partial: 'layouts/collapse_button' + = render partial: 'layouts/collapse_button' - if defined?(sidebar) && sidebar = render "layouts/nav/#{sidebar}" - elsif current_user @@ -14,6 +13,9 @@ = image_tag avatar_icon(current_user, 60), alt: 'Profile', class: 'avatar avatar s36' .username = current_user.username + = link_to '#', class: "nav-header-btn text-center pin-nav-btn #{'is-active' if pinned_nav?} js-nav-pin", title: 'Pin/Unpin navigation' do + %span.sr-only Toggle navigation pinning + = icon('thumb-tack') - if defined?(nav) && nav .layout-nav .container-fluid From 01d9bffdd82c098a8c2e368e39a590e5c753dbc7 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 12:43:32 -0600 Subject: [PATCH 151/195] Improve New Project page for mobile. Separate the New Project page's "Project path" grouped fields into separate fields. Fixes #18599. --- app/assets/stylesheets/pages/projects.scss | 10 +++------- app/views/projects/new.html.haml | 22 +++++++++------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0e4cefc55c..c85d23a31f 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -5,10 +5,12 @@ font-weight: normal; } } + .no-ssh-key-message, .project-limit-message { background-color: #f28d35; margin-bottom: 0; } + .new_project, .edit-project { fieldset.features { @@ -18,13 +20,6 @@ } } -.project-name-holder { - .help-inline { - vertical-align: top; - padding: 7px; - } -} - .project-home-panel { background: $white-light; text-align: left; @@ -376,6 +371,7 @@ a.deploy-project-label { .project-import .btn { float: left; + margin-bottom: 10px; margin-right: 10px; } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index f9ac16b32f..47a2f2889d 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -11,21 +11,17 @@ .project-edit-content = form_for @project, html: { class: 'new_project form-horizontal js-requires-input' } do |f| - .form-group.project-name-holder + .form-group = f.label :path, class: 'control-label' do - Project path + Project owner .col-sm-10 - .input-group - - if current_user.can_select_namespace? - .input-group-addon - = root_url - = f.select :namespace_id, namespaces_options(params[:namespace_id] || :current_user, display_path: true), {}, {class: 'select2 js-select-namespace', tabindex: 1} - .input-group-addon - \/ - - else - .input-group-addon - #{root_url}#{current_user.username}/ - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true + = f.select :namespace_id, namespaces_options(:current_user), {}, {class: 'select2 js-select-namespace', tabindex: 1} + + .form-group + = f.label :path, class: 'control-label' do + Project name + .col-sm-10 + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true - if current_user.can_create_group? .help-block From fbc91599a8fcd8bcd189d81a136b1bcc2c989fae Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 12:45:27 -0600 Subject: [PATCH 152/195] Fix test. --- features/steps/dashboard/new_project.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index a0aad66184..5308e77fb1 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -10,7 +10,8 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps end step 'I see "New Project" page' do - expect(page).to have_content('Project path') + expect(page).to have_content('Project owner') + expect(page).to have_content('Project name') end step 'I see all possible import optios' do From c69715bafa28964a71f65dfeba892b85c8cf73c9 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Wed, 15 Jun 2016 11:50:17 -0700 Subject: [PATCH 153/195] Update CHANGELOG for 8.8.5 release [ci skip] --- CHANGELOG | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b916f880ee..fa9cba510a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -102,14 +102,16 @@ v 8.9.0 (unreleased) - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed - Set inverse_of for Project/Service association to reduce the number of queries -v 8.8.5 (unreleased) - - Ensure branch cleanup regardless of whether the GitHub import process succeeds - - Fix todos page throwing errors when you have a project pending deletion - - Reduce number of SQL queries when rendering user references - - Import GitHub repositories respecting the API rate limit - - Fix importer for GitHub comments on diff - - Disable Webhooks before proceeding with the GitHub import - - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace +v 8.8.5 + - Import GitHub repositories respecting the API rate limit !4166 + - Fix todos page throwing errors when you have a project pending deletion !4300 + - Disable Webhooks before proceeding with the GitHub import !4470 + - Fix importer for GitHub comments on diff !4488 + - Adjust the SAML control flow to allow LDAP identities to be added to an existing SAML user !4498 + - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace !4541 + - Prevent unauthorized access for projects build traces + - Forbid scripting for wiki files + - Only show notes through JSON on confidential issues that the user has access to v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 From 190685741c47aff3ec53fb55308a36b46d1ef8d2 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 12:55:05 -0600 Subject: [PATCH 154/195] Move group creation text to below the 'Project owner' field. --- app/views/projects/new.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 47a2f2889d..7e8b8f8346 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -17,17 +17,17 @@ .col-sm-10 = f.select :namespace_id, namespaces_options(:current_user), {}, {class: 'select2 js-select-namespace', tabindex: 1} + - if current_user.can_create_group? + .help-block + Want to house several dependent projects under the same namespace? + = link_to "Create a group", new_group_path + .form-group = f.label :path, class: 'control-label' do Project name .col-sm-10 = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true - - if current_user.can_create_group? - .help-block - Want to house several dependent projects under the same namespace? - = link_to "Create a group", new_group_path - - if import_sources_enabled? .project-import.js-toggle-container .form-group From 10ae756a8623170ef5ca924364231a4ce341ce23 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 15:35:27 -0500 Subject: [PATCH 155/195] Fix project header alignment media query bug --- app/assets/stylesheets/pages/projects.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c85d23a31f..855d86cb23 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -28,7 +28,7 @@ .container-fluid { position: relative; - @media (min-width: $screen-md-max) { + @media (min-width: $screen-lg-min) { .row { display: flex; -ms-flex-align: center; @@ -224,7 +224,7 @@ right: 16px; bottom: 0; - @media (max-width: $screen-lg-min) { + @media (max-width: $screen-md-max) { top: 0; } @@ -233,7 +233,7 @@ right: 0; bottom: 61px; - @media (max-width: $screen-lg-min) { + @media (max-width: $screen-md-max) { position: relative; bottom: 0; margin-right: 10px; From 433a65db776ac6be39b60729c8e0dde12ac6659b Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 16:09:38 -0500 Subject: [PATCH 156/195] Lighten each logo path color instead of white --- app/assets/stylesheets/framework/header.scss | 32 ++++++++++++++++--- app/assets/stylesheets/framework/sidebar.scss | 11 ------- .../stylesheets/framework/variables.scss | 5 +++ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index dca4dbb9f7..78e6f5914a 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -2,6 +2,17 @@ * Application Header * */ +@mixin tanuki-logo-colors($path-color) { + fill: $path-color; + transition: all 0.8s; + + &:hover, + &.highlight { + fill: lighten($path-color, 25%); + transition: all 0.1s; + } +} + header { transition: padding $sidebar-transition-duration; @@ -191,13 +202,24 @@ header { } } -.tanuki-shape { - transition: all 0.8s; +#tanuki-logo { - &:hover, &.highlight { - fill: rgb(255, 255, 255); - transition: all 0.1s; + #tanuki-left-ear, + #tanuki-right-ear, + #tanuki-nose { + @include tanuki-logo-colors($tanuki-red); } + + #tanuki-left-eye, + #tanuki-right-eye { + @include tanuki-logo-colors($tanuki-orange); + } + + #tanuki-left-cheek, + #tanuki-right-cheek { + @include tanuki-logo-colors($tanuki-yellow); + } + } @media (max-width: $screen-xs-max) { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 281c0a0e1e..a0bb3427af 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -55,17 +55,6 @@ } } - -.tanuki-shape { - transition: all 0.8s; - - &:hover, &.highlight { - fill: rgb(255, 255, 255); - transition: all 0.1s; - } -} - - .nav-sidebar { position: absolute; top: 50px; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index acada1f16a..148b00ac85 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -156,6 +156,11 @@ $warning-message-border: #f0e2bb; /* header */ $light-grey-header: #faf9f9; +/* tanuki logo colors */ +$tanuki-red: #e24329; +$tanuki-orange: #fc6d26; +$tanuki-yellow: #fca326; + /* * State colors: */ From e4b32e49411c33aaa754a7adb42339752ebb3eb2 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 16:13:21 -0500 Subject: [PATCH 157/195] Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6533f2ea49..8556c368c4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -103,6 +103,7 @@ v 8.9.0 (unreleased) - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed - Set inverse_of for Project/Service association to reduce the number of queries + - Update tanuki logo highlight/loading colors v 8.8.5 - Import GitHub repositories respecting the API rate limit !4166 From 44df30c4c0dc5960bf6f9f0175fc5c6b3b57328f Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz@gitlab.com> Date: Wed, 15 Jun 2016 21:18:04 +0000 Subject: [PATCH 158/195] Revert "Merge branch '18047-event-item-links-dont-look-like-links' into 'master'" This reverts merge request !4544 --- app/assets/stylesheets/pages/events.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index dde189a21d..6fe57c737b 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -21,7 +21,7 @@ } a { - color: $gl-link-color; + color: $gl-dark-link-color; } .avatar { From 8033afd817826a1d9e00ae189aec64870224c2a6 Mon Sep 17 00:00:00 2001 From: Drew Blessing <drew@blessing.io> Date: Wed, 15 Jun 2016 21:38:12 +0000 Subject: [PATCH 159/195] Update migration_style_guide.md with new details --- doc/development/migration_style_guide.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 02e024ca15..8a7547e532 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -34,6 +34,15 @@ First, you need to provide information on whether the migration can be applied: 3. online with errors on new instances while migrating 4. offline (needs to happen without app servers to prevent db corruption) +For example: + +``` +# rubocop:disable all +# Migration type: online without errors (works on previous version and new one) +class MyMigration < ActiveRecord::Migration +... +``` + It is always preferable to have a migration run online. If you expect the migration to take particularly long (for instance, if it loops through all notes), this is valuable information to add. @@ -48,7 +57,6 @@ be possible to downgrade in case of a vulnerability or bugs. In your migration, add a comment describing how the reversibility of the migration was tested. - ## Removing indices If you need to remove index, please add a condition like in following example: @@ -70,6 +78,7 @@ so: ``` class MyMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers disable_ddl_transaction! def change @@ -90,8 +99,11 @@ value of `10` you'd write the following: ``` class MyMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + def up - add_column_with_default(:projects, :foo, :integer, 10) + add_column_with_default(:projects, :foo, :integer, default: 10) end def down From 8d6cfd79221a689415cbe7a86fd6308d19ab56d2 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 18:39:45 -0300 Subject: [PATCH 160/195] Update CHANGELOG for 8.2.6 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index faf2e28eeb..18fb2e6e1c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -966,6 +966,10 @@ v 8.3.0 - Expose Git's version in the admin area - Show "New Merge Request" buttons on canonical repos when you have a fork (Josh Frye) +v 8.2.6 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.2.5 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 2c26ba42c02fc866a3892c379853153005272e6f Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:08:47 -0300 Subject: [PATCH 161/195] Update CHANGELOG for 8.3.10 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 18fb2e6e1c..8c4deceef9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -848,6 +848,10 @@ v 8.4.0 - Add IP check against DNSBLs at account sign-up - Added cache:key to .gitlab-ci.yml allowing to fine tune the caching +v 8.3.10 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.3.9 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 510b2522f6fdd932ae1f7409748c76da1025579a Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:10:44 -0300 Subject: [PATCH 162/195] Update CHANGELOG for 8.4.11 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 8c4deceef9..bbb4fdd135 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -712,6 +712,10 @@ v 8.5.0 - Show label row when filtering issues or merge requests by label (Nuttanart Pornprasitsakul) - Add Todos +v 8.4.11 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.4.10 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 77554eee57b848cf5c5a0fe3ac47a7fc9afde127 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:11:34 -0300 Subject: [PATCH 163/195] Update CHANGELOG for 8.5.13 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index bbb4fdd135..b6758c7dfb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -551,6 +551,10 @@ v 8.6.0 - Trigger a todo for mentions on commits page - Let project owners and admins soft delete issues and merge requests +v 8.5.13 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.5.12 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From cb9bab945b1338d593dada812714abe4e7f33fd7 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:13:33 -0300 Subject: [PATCH 164/195] Update CHANGELOG for 8.6.9 release [skip] --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index b6758c7dfb..2a414c3ab4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -397,6 +397,11 @@ v 8.7.0 - Add RAW build trace output and button on build page - Add incremental build trace update into CI API +v 8.6.9 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + - Only show notes through JSON on confidential issues that the user has access to + v 8.6.8 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 1f81137bb47843a518dd8dc3c2bc4b5f6ef180e5 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:16:02 -0300 Subject: [PATCH 165/195] Update CHANGELOG for 8.7.7 release [ci skip] --- CHANGELOG | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 2a414c3ab4..03b9178da3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -235,6 +235,9 @@ v 8.8.0 v 8.7.7 - Fix import by `Any Git URL` broken if the URL contains a space + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + - Only show notes through JSON on confidential issues that the user has access to v 8.7.6 - Fix links on wiki pages for relative url setups. !4131 (Artem Sidorenko) From 94135e6275a0c538ab0a5782c3f71152894efc2d Mon Sep 17 00:00:00 2001 From: Ilan Shamir <ilanshamir1@gmail.com> Date: Tue, 14 Jun 2016 23:43:31 +0300 Subject: [PATCH 166/195] Remove JiraIssue model and replace references with ExternalIssue --- CHANGELOG | 1 + app/models/jira_issue.rb | 2 -- spec/helpers/merge_requests_helper_spec.rb | 6 ++-- spec/lib/gitlab/reference_extractor_spec.rb | 3 +- spec/models/jira_issue_spec.rb | 30 ------------------- .../project_services/jira_service_spec.rb | 6 ++-- spec/services/system_note_service_spec.rb | 2 +- 7 files changed, 11 insertions(+), 39 deletions(-) delete mode 100644 app/models/jira_issue.rb delete mode 100644 spec/models/jira_issue_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 162c6723dd..49b95dee48 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -51,6 +51,7 @@ v 8.9.0 (unreleased) - Projects pending deletion will render a 404 page - Measure queue duration between gitlab-workhorse and Rails - Make Omniauth providers specs to not modify global configuration + - Remove unused JiraIssue class and replace references with ExternalIssue - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) - Cache assigned issue and merge request counts in sidebar nav diff --git a/app/models/jira_issue.rb b/app/models/jira_issue.rb deleted file mode 100644 index 5b21aac5e4..0000000000 --- a/app/models/jira_issue.rb +++ /dev/null @@ -1,2 +0,0 @@ -class JiraIssue < ExternalIssue -end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index a3336c8717..903224589d 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -33,9 +33,9 @@ describe MergeRequestsHelper do let(:project) { create(:project) } let(:issues) do [ - JiraIssue.new('JIRA-123', project), - JiraIssue.new('JIRA-456', project), - JiraIssue.new('FOOBAR-7890', project) + ExternalIssue.new('JIRA-123', project), + ExternalIssue.new('JIRA-456', project), + ExternalIssue.new('FOOBAR-7890', project) ] end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 7c617723e6..7b4ccc8391 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -105,7 +105,8 @@ describe Gitlab::ReferenceExtractor, lib: true do it 'returns JIRA issues for a JIRA-integrated project' do subject.analyze('JIRA-123 and FOOBAR-4567') - expect(subject.issues).to eq [JiraIssue.new('JIRA-123', project), JiraIssue.new('FOOBAR-4567', project)] + expect(subject.issues).to eq [ExternalIssue.new('JIRA-123', project), + ExternalIssue.new('FOOBAR-4567', project)] end end diff --git a/spec/models/jira_issue_spec.rb b/spec/models/jira_issue_spec.rb deleted file mode 100644 index 1634265b43..0000000000 --- a/spec/models/jira_issue_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'spec_helper' - -describe JiraIssue do - let(:project) { create(:project) } - subject { JiraIssue.new('JIRA-123', project) } - - describe 'id' do - subject { super().id } - it { is_expected.to eq('JIRA-123') } - end - - describe 'iid' do - subject { super().iid } - it { is_expected.to eq('JIRA-123') } - end - - describe 'to_s' do - subject { super().to_s } - it { is_expected.to eq('JIRA-123') } - end - - describe :== do - specify { expect(subject).to eq(JiraIssue.new('JIRA-123', project)) } - specify { expect(subject).not_to eq(JiraIssue.new('JIRA-124', project)) } - - it 'only compares with JiraIssues' do - expect(subject).not_to eq('JIRA-123') - end - end -end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 5309cfb99f..c951732454 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -76,7 +76,8 @@ describe JiraService, models: true do end it "should call JIRA API" do - @jira_service.execute(merge_request, JiraIssue.new("JIRA-123", project)) + @jira_service.execute(merge_request, + ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @comment_url).with( body: /Issue solved with/ ).once @@ -84,7 +85,8 @@ describe JiraService, models: true do it "calls the api with jira_issue_transition_id" do @jira_service.jira_issue_transition_id = 'this-is-a-custom-id' - @jira_service.execute(merge_request, JiraIssue.new("JIRA-123", project)) + @jira_service.execute(merge_request, + ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @api_url).with( body: /this-is-a-custom-id/ ).once diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 09f0ee3871..85dd30bf48 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -529,7 +529,7 @@ describe SystemNoteService, services: true do let(:author) { create(:user) } let(:issue) { create(:issue, project: project) } let(:mergereq) { create(:merge_request, :simple, target_project: project, source_project: project) } - let(:jira_issue) { JiraIssue.new("JIRA-1", project)} + let(:jira_issue) { ExternalIssue.new("JIRA-1", project)} let(:jira_tracker) { project.create_jira_service if project.jira_service.nil? } let(:commit) { project.commit } From 7ee0898a9ec4a03c9a55841b1cbea67add460c50 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Thu, 16 Jun 2016 08:24:13 +0530 Subject: [PATCH 167/195] Implement @DouweM's feedback. - Extract a duplicated `redirect_to` - Fix a typo: "token", not "certificate" - Have the "Expires at" datepicker be attached to a text field, not inline - Have both private tokens and personal access tokens verified in a single "authenticate_from_private_token" method, both in the application and API. Move relevant logic to `User#find_by_personal_access_token` - Remove unnecessary constants relating to API auth. We don't need a separate constant for personal access tokens since the param is the same as for private tokens. --- app/controllers/application_controller.rb | 22 +++++-------------- .../personal_access_tokens_controller.rb | 6 +++-- app/models/user.rb | 5 +++++ app/views/layouts/nav/_profile.html.haml | 1 - .../personal_access_tokens/index.html.haml | 15 +++---------- lib/api/helpers.rb | 14 +++--------- .../profiles/personal_access_tokens_spec.rb | 1 + 7 files changed, 22 insertions(+), 42 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a5b358f10f..72d1b97bf5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,7 +8,7 @@ class ApplicationController < ActionController::Base include PageLayoutHelper include WorkhorseHelper - before_action :authenticate_user_from_token! + before_action :authenticate_user_from_private_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :reject_blocked! @@ -64,8 +64,11 @@ class ApplicationController < ActionController::Base end end - def authenticate_user_from_token! - user = get_user_from_private_token || get_user_from_personal_access_token + # This filter handles both private tokens and personal access tokens + def authenticate_user_from_private_token! + token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence + user = User.find_by_authentication_token(token_string) || User.find_by_personal_access_token(token_string) + if user # Notice we are passing store false, so the user is not # actually stored in the session and a token is needed @@ -376,17 +379,4 @@ class ApplicationController < ActionController::Base (controller_name == 'groups' && action_name == page_type) || (controller_name == 'dashboard' && action_name == page_type) end - - # From https://github.com/plataformatec/devise/wiki/How-To:-Simple-Token-Authentication-Example - # https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 - def get_user_from_private_token - user_token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence - User.find_by_authentication_token(user_token.to_s) if user_token - end - - def get_user_from_personal_access_token - token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence - personal_access_token = PersonalAccessToken.active.find_by_token(token_string) if token_string - personal_access_token.user if personal_access_token - end end diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 86e08fed8e..508b82a9a6 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -21,10 +21,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController @personal_access_token = current_user.personal_access_tokens.find(params[:id]) if @personal_access_token.revoke! - redirect_to profile_personal_access_tokens_path, notice: "Revoked personal access token #{@personal_access_token.name}!" + flash[:notice] = "Revoked personal access token #{@personal_access_token.name}!" else - redirect_to profile_personal_access_tokens_path, alert: "Could not revoke personal access token #{@personal_access_token.name}." + flash[:alert] = "Could not revoke personal access token #{@personal_access_token.name}." end + + redirect_to profile_personal_access_tokens_path end private diff --git a/app/models/user.rb b/app/models/user.rb index 65fe88e028..388e2652ba 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -269,6 +269,11 @@ class User < ActiveRecord::Base find_by!('lower(username) = ?', username.downcase) end + def find_by_personal_access_token(token_string) + personal_access_token = PersonalAccessToken.active.find_by_token(token_string) if token_string + personal_access_token.user if personal_access_token + end + def by_username_or_id(name_or_id) find_by('users.username = ? OR users.id = ?', name_or_id.to_s, name_or_id.to_i) end diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index d9f1e1c0ac..bb6f14a622 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -15,7 +15,6 @@ Applications = nav_link(controller: :personal_access_tokens) do = link_to profile_personal_access_tokens_path, title: 'Personal Access Tokens' do - = icon('ticket fw') %span Personal Access Tokens = nav_link(controller: :emails) do diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 456e770dc8..1b45548bd0 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -34,8 +34,7 @@ .form-group = f.label :expires_at, class: 'label-light' - = f.hidden_field :expires_at, class: "form-control", required: false - .datepicker.personal-access-tokens-expires-at + = f.text_field :expires_at, class: "datepicker form-control", required: false .prepend-top-default = f.submit 'Create Personal Access Token', class: "btn btn-create" @@ -63,7 +62,7 @@ = token.expires_at.to_date.to_s(:medium) - else %span.personal-access-tokens-never-expires-label Never - %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this certificate? This action cannot be undone." } + %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this token? This action cannot be undone." } - else .settings-message.text-center @@ -95,18 +94,10 @@ var date = $('#personal_access_token_expires_at').val(); var datepicker = $(".datepicker").datepicker({ - altFormat: "yy-mm-dd", - altField: "#personal_access_token_expires_at", + dateFormat: "yy-mm-dd", minDate: 0 }); - if (date) { - datepicker.datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', date)); - } - else { - datepicker.datepicker("setDate", null); - } - $("#created-personal-access-token").click(function() { this.select(); }); diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 8c4a707e7e..77e407b54c 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -4,26 +4,18 @@ module API PRIVATE_TOKEN_PARAM = :private_token SUDO_HEADER = "HTTP_SUDO" SUDO_PARAM = :sudo - PERSONAL_ACCESS_TOKEN_PARAM = PRIVATE_TOKEN_PARAM - PERSONAL_ACCESS_TOKEN_HEADER = PRIVATE_TOKEN_HEADER def parse_boolean(value) [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value) end def find_user_by_private_token - private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s - User.find_by_authentication_token(private_token) - end - - def find_user_by_personal_access_token - personal_access_token_string = (params[PERSONAL_ACCESS_TOKEN_PARAM] || env[PERSONAL_ACCESS_TOKEN_HEADER]).to_s - personal_access_token = PersonalAccessToken.active.find_by_token(personal_access_token_string) - personal_access_token.user if personal_access_token + token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + User.find_by_authentication_token(token_string) || User.find_by_personal_access_token(token_string) end def current_user - @current_user ||= (find_user_by_private_token || find_user_by_personal_access_token || doorkeeper_guard) + @current_user ||= (find_user_by_private_token || doorkeeper_guard) unless @current_user && Gitlab::UserAccess.allowed?(@current_user) return nil diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index d824e1d288..a85930c754 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -41,6 +41,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do fill_in "Name", with: FFaker::Product.brand # Set date to 1st of next month + find_field("Expires at").trigger('focus') find("a[title='Next']").click click_on "1" From e8a467e0943cfc5aea1c2c42680bfa61e1733cc7 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Wed, 15 Jun 2016 02:12:42 -0500 Subject: [PATCH 168/195] Implements TemplateDropdown class to create custom template dropdowns Also License dropdown has been ported to use our GL dropdown instead of Select2. Fixes tests to make it work with current implementation --- .../blob/blob_gitignore_selector.js.coffee | 61 ++----------------- .../blob/blob_gitignore_selectors.js.coffee | 17 ++++++ .../blob/blob_license_selector.js.coffee | 35 +++-------- .../blob/blob_license_selectors.js.coffee | 17 ++++++ .../javascripts/blob/edit_blob.js.coffee | 5 +- .../blob/template_selector.js.coffee | 56 +++++++++++++++++ app/assets/stylesheets/pages/editor.scss | 3 +- app/helpers/blob_helper.rb | 4 +- app/views/projects/blob/_editor.html.haml | 10 ++- ...project_owner_creates_license_file_spec.rb | 14 ++++- ...eate_license_file_in_empty_project_spec.rb | 12 +++- .../labels/update_prioritization_spec.rb | 1 + 12 files changed, 133 insertions(+), 102 deletions(-) create mode 100644 app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee create mode 100644 app/assets/javascripts/blob/blob_license_selectors.js.coffee create mode 100644 app/assets/javascripts/blob/template_selector.js.coffee diff --git a/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee b/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee index cc8a497d08..8d0e3f363d 100644 --- a/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee +++ b/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee @@ -1,58 +1,5 @@ -class @BlobGitignoreSelector - constructor: (opts) -> - { - @dropdown - @editor - @$wrapper = @dropdown.closest('.gitignore-selector') - @$filenameInput = $('#file_name') - @data = @dropdown.data('filenames') - } = opts +#= require blob/template_selector - @dropdown.glDropdown( - data: @data, - filterable: true, - selectable: true, - search: - fields: ['name'] - clicked: @onClick - text: (gitignore) -> - gitignore.name - ) - - @toggleGitignoreSelector() - @bindEvents() - - bindEvents: -> - @$filenameInput - .on 'keyup blur', (e) => - @toggleGitignoreSelector() - - toggleGitignoreSelector: -> - filename = @$filenameInput.val() or $('.editor-file-name').text().trim() - @$wrapper.toggleClass 'hidden', filename isnt '.gitignore' - - onClick: (item, el, e) => - e.preventDefault() - @requestIgnoreFile(item.name) - - requestIgnoreFile: (name) -> - Api.gitignoreText name, @requestIgnoreFileSuccess.bind(@) - - requestIgnoreFileSuccess: (gitignore) -> - @editor.setValue(gitignore.content, 1) - @editor.focus() - -class @BlobGitignoreSelectors - constructor: (opts) -> - { - @$dropdowns = $('.js-gitignore-selector') - @editor - } = opts - - @$dropdowns.each (i, dropdown) => - $dropdown = $(dropdown) - - new BlobGitignoreSelector( - dropdown: $dropdown, - editor: @editor - ) +class @BlobGitignoreSelector extends TemplateSelector + requestFile: (query) -> + Api.gitignoreText query.name, @requestFileSuccess.bind(@) diff --git a/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee b/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee new file mode 100644 index 0000000000..a719ba2512 --- /dev/null +++ b/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee @@ -0,0 +1,17 @@ +class @BlobGitignoreSelectors + constructor: (opts) -> + { + @$dropdowns = $('.js-gitignore-selector') + @editor + } = opts + + @$dropdowns.each (i, dropdown) => + $dropdown = $(dropdown) + + new BlobGitignoreSelector( + pattern: /(.gitignore)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-gitignore-selector-wrap'), + dropdown: $dropdown, + editor: @editor + ) diff --git a/app/assets/javascripts/blob/blob_license_selector.js.coffee b/app/assets/javascripts/blob/blob_license_selector.js.coffee index e17eaa75dc..a3cc8dd844 100644 --- a/app/assets/javascripts/blob/blob_license_selector.js.coffee +++ b/app/assets/javascripts/blob/blob_license_selector.js.coffee @@ -1,30 +1,9 @@ -class @BlobLicenseSelector - licenseRegex: /^(.+\/)?(licen[sc]e|copying)($|\.)/i +#= require blob/template_selector - constructor: (editor) -> - @$licenseSelector = $('.js-license-selector') - $fileNameInput = $('#file_name') +class @BlobLicenseSelector extends TemplateSelector + requestFile: (query) -> + data = + project: @dropdown.data('project') + fullname: @dropdown.data('fullname') - initialFileNameValue = if $fileNameInput.length - $fileNameInput.val() - else if $('.editor-file-name').length - $('.editor-file-name').text().trim() - - @toggleLicenseSelector(initialFileNameValue) - - if $fileNameInput - $fileNameInput.on 'keyup blur', (e) => - @toggleLicenseSelector($(e.target).val()) - - $('select.license-select').on 'change', (e) -> - data = - project: $(this).data('project') - fullname: $(this).data('fullname') - Api.licenseText $(this).val(), data, (license) -> - editor.setValue(license.content, -1) - - toggleLicenseSelector: (fileName) => - if @licenseRegex.test(fileName) - @$licenseSelector.show() - else - @$licenseSelector.hide() + Api.licenseText query.id, data, @requestFileSuccess.bind(@) diff --git a/app/assets/javascripts/blob/blob_license_selectors.js.coffee b/app/assets/javascripts/blob/blob_license_selectors.js.coffee new file mode 100644 index 0000000000..6843873310 --- /dev/null +++ b/app/assets/javascripts/blob/blob_license_selectors.js.coffee @@ -0,0 +1,17 @@ +class @BlobLicenseSelectors + constructor: (opts) -> + { + @$dropdowns = $('.js-license-selector') + @editor + } = opts + + @$dropdowns.each (i, dropdown) => + $dropdown = $(dropdown) + + new BlobLicenseSelector( + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-license-selector-wrap'), + dropdown: $dropdown, + editor: @editor + ) diff --git a/app/assets/javascripts/blob/edit_blob.js.coffee b/app/assets/javascripts/blob/edit_blob.js.coffee index 79141e768b..636f909dbd 100644 --- a/app/assets/javascripts/blob/edit_blob.js.coffee +++ b/app/assets/javascripts/blob/edit_blob.js.coffee @@ -12,8 +12,9 @@ class @EditBlob $("#file-content").val(@editor.getValue()) @initModePanesAndLinks() - new BlobLicenseSelector(@editor) - new BlobGitignoreSelectors(editor: @editor) + + new BlobLicenseSelectors { @editor } + new BlobGitignoreSelectors { @editor } initModePanesAndLinks: -> @$editModePanes = $(".js-edit-mode-pane") diff --git a/app/assets/javascripts/blob/template_selector.js.coffee b/app/assets/javascripts/blob/template_selector.js.coffee new file mode 100644 index 0000000000..e76e303189 --- /dev/null +++ b/app/assets/javascripts/blob/template_selector.js.coffee @@ -0,0 +1,56 @@ +class @TemplateSelector + constructor: (opts = {}) -> + { + @dropdown, + @data, + @pattern, + @wrapper, + @editor, + @fileEndpoint, + @$input = $('#file_name') + } = opts + + @buildDropdown() + @bindEvents() + @onFilenameUpdate() + + buildDropdown: -> + @dropdown.glDropdown( + data: @data, + filterable: true, + selectable: true, + search: + fields: ['name'] + clicked: @onClick + text: (item) -> + item.name + ) + + bindEvents: -> + @$input.on('keyup blur', (e) => + @onFilenameUpdate() + ) + + onFilenameUpdate: -> + return unless @$input.length + + filenameMatches = @pattern.test(@$input.val().trim()) + + if not filenameMatches + @wrapper.addClass('hidden') + return + + @wrapper.removeClass('hidden') + + onClick: (item, el, e) => + e.preventDefault() + @requestFile(item) + + requestFile: (item) -> + # To be implemented on the extending class + # e.g. + # Api.gitignoreText item.name, @requestFileSuccess.bind(@) + + requestFileSuccess: (file) -> + @editor.setValue(file.content, 1) + @editor.focus() diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 22679c764d..a34b06f105 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -66,8 +66,7 @@ font-family: $regular_font; } - .gitignore-selector { - + .gitignore-selector, .license-selector { .dropdown { line-height: 21px; } diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 85559fbc5f..5b54b34070 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -180,8 +180,8 @@ module BlobHelper licenses = Licensee::License.all @licenses_for_select = { - Popular: licenses.select(&:featured).map { |license| [license.name, license.key] }, - Other: licenses.reject(&:featured).map { |license| [license.name, license.key] } + Popular: licenses.select(&:featured).map { |license| { name: license.name, id: license.key } }, + Other: licenses.reject(&:featured).map { |license| { name: license.name, id: license.key } } } end diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 4071b59c00..ae89637df6 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -13,12 +13,10 @@ required: true, class: 'form-control new-file-name' .pull-right - .license-selector.js-license-selector.hide - = select_tag :license_type, grouped_options_for_select(licenses_for_select, @project.repository.license_key), include_blank: true, class: 'select2 license-select', data: {placeholder: 'Choose a license template', project: @project.name, fullname: @project.namespace.human_name} - - .gitignore-selector.hidden - = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { filenames: gitignore_names } } ) - + .license-selector.js-license-selector-wrap.hidden + = dropdown_tag("Choose a License template", options: { toggle_class: 'js-license-selector', title: "Choose a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } ) + .gitignore-selector.js-gitignore-selector-wrap.hidden + = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) .encoding-selector = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index ecc818eb1e..e1e105e6bb 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'project owner creates a license file', feature: true, js: true do - include Select2Helper + include WaitForAjax let(:project_master) { create(:user) } let(:project) { create(:project) } @@ -21,7 +21,7 @@ feature 'project owner creates a license file', feature: true, js: true do expect(page).to have_selector('.license-selector') - select2('mit', from: '#license_type') + select_template('MIT License') file_content = find('.file-content') expect(file_content).to have_content('The MIT License (MIT)') @@ -44,7 +44,7 @@ feature 'project owner creates a license file', feature: true, js: true do expect(find('#file_name').value).to eq('LICENSE') expect(page).to have_selector('.license-selector') - select2('mit', from: '#license_type') + select_template('MIT License') file_content = find('.file-content') expect(file_content).to have_content('The MIT License (MIT)') @@ -58,4 +58,12 @@ feature 'project owner creates a license file', feature: true, js: true do expect(page).to have_content('The MIT License (MIT)') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end + + def select_template(template) + page.within('.js-license-selector-wrap') do + click_button 'Choose a License template' + click_link template + wait_for_ajax + end + end end diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 34eda29c28..67aac25e42 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'project owner sees a link to create a license file in empty project', feature: true, js: true do - include Select2Helper + include WaitForAjax let(:project_master) { create(:user) } let(:project) { create(:empty_project) } @@ -20,7 +20,7 @@ feature 'project owner sees a link to create a license file in empty project', f expect(find('#file_name').value).to eq('LICENSE') expect(page).to have_selector('.license-selector') - select2('mit', from: '#license_type') + select_template('MIT License') file_content = find('.file-content') expect(file_content).to have_content('The MIT License (MIT)') @@ -36,4 +36,12 @@ feature 'project owner sees a link to create a license file in empty project', f expect(page).to have_content('The MIT License (MIT)') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end + + def select_template(template) + page.within('.js-license-selector-wrap') do + click_button 'Choose a License template' + click_link template + wait_for_ajax + end + end end diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 8550d279d0..6a39c302f5 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -77,6 +77,7 @@ feature 'Prioritize labels', feature: true do end visit current_url + wait_for_ajax page.within('.prioritized-labels') do expect(first('li')).to have_content('wontfix') From 9d7cda3ddce52baad9618466a5d00319b333be57 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Thu, 16 Jun 2016 12:28:31 +0530 Subject: [PATCH 169/195] Fix `api_helpers_spec` --- spec/requests/api/api_helpers_spec.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/requests/api/api_helpers_spec.rb b/spec/requests/api/api_helpers_spec.rb index 06997147b0..f22db61e74 100644 --- a/spec/requests/api/api_helpers_spec.rb +++ b/spec/requests/api/api_helpers_spec.rb @@ -67,35 +67,35 @@ describe API::Helpers, api: true do let(:personal_access_token) { create(:personal_access_token, user: user) } it "should return nil for an invalid token" do - env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = 'invalid token' + env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it "should return nil for a user without access" do - env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end it "should leave user as is when sudo not specified" do - env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) clear_env - params[API::Helpers::PERSONAL_ACCESS_TOKEN_PARAM] = personal_access_token.token + params[API::Helpers::PRIVATE_TOKEN_PARAM] = personal_access_token.token expect(current_user).to eq(user) end it 'does not allow revoked tokens' do personal_access_token.revoke! - env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it 'does not allow expired tokens' do personal_access_token.update_attributes!(expires_at: 1.day.ago) - env[API::Helpers::PERSONAL_ACCESS_TOKEN_HEADER] = personal_access_token.token + env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end From da592a737c8dad6772a3340dbc5194eb5b3b4687 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 12:21:30 +0100 Subject: [PATCH 170/195] Added source branch text to dropdown toggle Previously, the dropdown toggle would default to "Select source branch", this changes that so that it defaults to the branch name and if that doesn't exist, it defaults to "Select source branch" --- CHANGELOG | 12 ++++++++++++ .../projects/merge_requests/_new_compare.html.haml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index fa0960b284..354ee450dd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -119,6 +119,18 @@ v 8.8.5 - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to +v 8.8.5 (unreleased) + - Adds selected branch name to the dropdown toggle + +v 8.8.4 (unreleased) + - Ensure branch cleanup regardless of whether the GitHub import process succeeds + - Fix todos page throwing errors when you have a project pending deletion + - Reduce number of SQL queries when rendering user references + - Import GitHub repositories respecting the API rate limit + - Fix importer for GitHub comments on diff + - Disable Webhooks before proceeding with the GitHub import + - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace + v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 - Added descriptions to notification settings dropdown diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index b08524574e..de39964fca 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -21,7 +21,7 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } + = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch = dropdown_title("Select source branch") = dropdown_filter("Search branches") From 7708d5a93d199d534a76abd16d6995940374b9c9 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 09:41:50 +0100 Subject: [PATCH 171/195] CHANGELOG [ci skip] --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 354ee450dd..e7c68a37af 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -66,6 +66,7 @@ v 8.9.0 (unreleased) - Make Omniauth providers specs to not modify global configuration - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) + - Adds selected branch name to the dropdown toggle - Cache assigned issue and merge request counts in sidebar nav - Use Knapsack only in CI environment - Cache project build count in sidebar nav From cd37c927e1415bc2a181283dd1ff484868337906 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 16 Jun 2016 08:43:10 +0100 Subject: [PATCH 172/195] CHANGELOG [ci skip] --- CHANGELOG | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e7c68a37af..c459e87690 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,7 @@ v 8.9.0 (unreleased) - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 - Upgrade to jQuery 2 + - Adds selected branch name to the dropdown toggle - Use Knapsack to evenly distribute tests across multiple nodes - Add `sha` parameter to MR merge API, to ensure only reviewed changes are merged - Don't allow MRs to be merged when commits were added since the last review / page load @@ -66,7 +67,6 @@ v 8.9.0 (unreleased) - Make Omniauth providers specs to not modify global configuration - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) - - Adds selected branch name to the dropdown toggle - Cache assigned issue and merge request counts in sidebar nav - Use Knapsack only in CI environment - Cache project build count in sidebar nav @@ -120,18 +120,6 @@ v 8.8.5 - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to -v 8.8.5 (unreleased) - - Adds selected branch name to the dropdown toggle - -v 8.8.4 (unreleased) - - Ensure branch cleanup regardless of whether the GitHub import process succeeds - - Fix todos page throwing errors when you have a project pending deletion - - Reduce number of SQL queries when rendering user references - - Import GitHub repositories respecting the API rate limit - - Fix importer for GitHub comments on diff - - Disable Webhooks before proceeding with the GitHub import - - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace - v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 - Added descriptions to notification settings dropdown From dda94e04062623702f03d427b17931a7c93f64c5 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Thu, 16 Jun 2016 10:43:47 +0200 Subject: [PATCH 173/195] Make project_id nullable --- ...616084004_change_project_of_environment.rb | 21 +++++++++++++++++++ db/schema.rb | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20160616084004_change_project_of_environment.rb diff --git a/db/migrate/20160616084004_change_project_of_environment.rb b/db/migrate/20160616084004_change_project_of_environment.rb new file mode 100644 index 0000000000..cc1daf9b62 --- /dev/null +++ b/db/migrate/20160616084004_change_project_of_environment.rb @@ -0,0 +1,21 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ChangeProjectOfEnvironment < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + change_column_null :environments, :project_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6a3be7297e..d6a542a89f 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: 20160615142710) do +ActiveRecord::Schema.define(version: 20160616084004) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" From 84e2be5a5f3f020f1c57b013e82143ff90e48e58 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 15 Jun 2016 15:22:05 +0200 Subject: [PATCH 174/195] Turn Group#owners into a has_many association This allows the owners to be eager loaded where needed. --- app/models/group.rb | 10 ++++++---- spec/models/group_spec.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index b8dffe9f5b..e66e04371b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -9,6 +9,12 @@ class Group < Namespace has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members + + has_many :owners, + -> { where(members: { access_level: Gitlab::Access::OWNER }) }, + through: :group_members, + source: :user + has_many :project_group_links, dependent: :destroy has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source @@ -88,10 +94,6 @@ class Group < Namespace end end - def owners - @owners ||= group_members.owners.includes(:user).map(&:user) - end - def add_users(user_ids, access_level, current_user = nil) user_ids.each do |user_id| Member.add_user(self.group_members, user_id, access_level, current_user) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index ccdcb29f77..2c19aa3f67 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -158,6 +158,18 @@ describe Group, models: true do it { expect(group.has_master?(@members[:requester])).to be_falsey } end + describe '#owners' do + let(:owner) { create(:user) } + let(:developer) { create(:user) } + + it 'returns the owners of a Group' do + group.add_owner(owner) + group.add_developer(developer) + + expect(group.owners).to eq([owner]) + end + end + def setup_group_members(group) members = { owner: create(:user), From 46696bde83736a83ec6f54f05795b003793b5865 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Wed, 15 Jun 2016 19:00:50 +0200 Subject: [PATCH 175/195] Banzai::Filter::UploadLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/upload_link_filter.rb | 11 +++------- .../banzai/filter/upload_link_filter_spec.rb | 20 +++++++++++++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fa0960b284..39532e8813 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -118,6 +118,7 @@ v 8.8.5 - Prevent unauthorized access for projects build traces - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to + - Banzai::Filter::UploadLinkFilter use XPath instead CSS expressions v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb index c0f503c9af..45bb66dc99 100644 --- a/lib/banzai/filter/upload_link_filter.rb +++ b/lib/banzai/filter/upload_link_filter.rb @@ -10,11 +10,11 @@ module Banzai def call return doc unless project - doc.search('a').each do |el| + doc.xpath('descendant-or-self::a[starts-with(@href, "/uploads/")]').each do |el| process_link_attr el.attribute('href') end - doc.search('img').each do |el| + doc.xpath('descendant-or-self::img[starts-with(@src, "/uploads/")]').each do |el| process_link_attr el.attribute('src') end @@ -24,12 +24,7 @@ module Banzai protected def process_link_attr(html_attr) - return if html_attr.blank? - - uri = html_attr.value - if uri.starts_with?("/uploads/") - html_attr.value = build_url(uri).to_s - end + html_attr.value = build_url(html_attr.value).to_s end def build_url(uri) diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb index b83be54746..273d2ed709 100644 --- a/spec/lib/banzai/filter/upload_link_filter_spec.rb +++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb @@ -23,6 +23,14 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do %(<a href="#{path}">#{path}</a>) end + def nested_image(path) + %(<div><img src="#{path}" /></div>) + end + + def nested_link(path) + %(<div><a href="#{path}">#{path}</a></div>) + end + let(:project) { create(:project) } shared_examples :preserve_unchanged do @@ -47,11 +55,19 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do doc = filter(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) expect(doc.at_css('a')['href']). to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" + + doc = filter(nested_link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) + expect(doc.at_css('a')['href']). + to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" end it 'rebuilds relative URL for an image' do - doc = filter(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) - expect(doc.at_css('a')['href']). + doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) + expect(doc.at_css('img')['src']). + to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" + + doc = filter(nested_image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) + expect(doc.at_css('img')['src']). to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" end From 5eb9cb68f797f8a3e05e4a00c5657c2dd3250c68 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Thu, 16 Jun 2016 11:47:01 +0100 Subject: [PATCH 176/195] Fix images in emails --- CHANGELOG | 1 + config/initializers/default_url_options.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index fa0960b284..c9f745f6dd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.9.0 (unreleased) - Add a metric for the number of new Redis connections created by a transaction - Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark - Redesign navigation for project pages + - Fix images in sign-up confirmation email - Added shortcut 'y' for copying a files content hash URL #14470 - Fix groups API to list only user's accessible projects - Fix horizontal scrollbar for long commit message. diff --git a/config/initializers/default_url_options.rb b/config/initializers/default_url_options.rb index 8fd27b1d88..de2cdc6eca 100644 --- a/config/initializers/default_url_options.rb +++ b/config/initializers/default_url_options.rb @@ -9,3 +9,4 @@ unless Gitlab.config.gitlab_on_standard_port? end Rails.application.routes.default_url_options = default_url_options +ActionMailer::Base.asset_host = Settings.gitlab['base_url'] From 425987861530c9c0fb7fe618d7f4bab017a80253 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 15 Jun 2016 18:07:04 +0200 Subject: [PATCH 177/195] Fixed ordering in Project.find_with_namespace This ensures that Project.find_with_namespace returns a row matching literally as the first value, instead of returning a random value. The ordering here is _only_ applied to Project.find_with_namespace and _not_ Project.where_paths_in as currently there's no code that requires Project.where_paths_in to return rows in a certain order. Since this method also returns all rows that match there's no real harm in not setting a specific order either. Another reason is that generating all the "WHEN" arms for multiple values in Project.where_paths_in becomes really messy. On MySQL we have to use the "BINARY" operator to turn a "WHERE" into a case-sensitive WHERE as otherwise MySQL may still end up returning rows in an unpredictable order. Fixes gitlab-org/gitlab-ce#18603 --- app/models/project.rb | 18 +++++++++++++++++- spec/models/project_spec.rb | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index fdbc84474e..9228ccab71 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -262,7 +262,23 @@ class Project < ActiveRecord::Base # # Returns a Project, or nil if no project could be found. def find_with_namespace(path) - where_paths_in([path]).reorder(nil).take + namespace_path, project_path = path.split('/', 2) + + return unless namespace_path && project_path + + namespace_path = connection.quote(namespace_path) + project_path = connection.quote(project_path) + + # On MySQL we want to ensure the ORDER BY uses a case-sensitive match so + # any literal matches come first, for this we have to use "BINARY". + # Without this there's still no guarantee in what order MySQL will return + # rows. + binary = Gitlab::Database.mysql? ? 'BINARY' : '' + + order_sql = "(CASE WHEN #{binary} namespaces.path = #{namespace_path} " \ + "AND #{binary} projects.path = #{project_path} THEN 0 ELSE 1 END)" + + where_paths_in([path]).reorder(order_sql).take end # Builds a relation to find multiple projects by their full paths. diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index fedab1f913..53c8408633 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -220,7 +220,7 @@ describe Project, models: true do end end - describe :find_with_namespace do + describe '.find_with_namespace' do context 'with namespace' do before do @group = create :group, name: 'gitlab' @@ -231,6 +231,22 @@ describe Project, models: true do it { expect(Project.find_with_namespace('GitLab/GitlabHQ')).to eq(@project) } it { expect(Project.find_with_namespace('gitlab-ci')).to be_nil } end + + context 'when multiple projects using a similar name exist' do + let(:group) { create(:group, name: 'gitlab') } + + let!(:project1) do + create(:empty_project, name: 'gitlab1', path: 'gitlab', namespace: group) + end + + let!(:project2) do + create(:empty_project, name: 'gitlab2', path: 'GITLAB', namespace: group) + end + + it 'returns the row where the path matches literally' do + expect(Project.find_with_namespace('gitlab/GITLAB')).to eq(project2) + end + end end describe :to_param do From 18d4bd9564c514018f955241f774dce40c83ce52 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer <jacob@gitlab.com> Date: Thu, 16 Jun 2016 13:20:12 +0200 Subject: [PATCH 178/195] Use gitlab_git 10.1.4 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a22bd1b1f5..2b28cb0d91 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -277,7 +277,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (10.1.3) + gitlab_git (10.1.4) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) From 19a290e7bfcb5e74a0e9975fd3f7396ca0e2e990 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 1 Jun 2016 17:39:12 +0200 Subject: [PATCH 179/195] Reduce queries in IssueReferenceFilter This reduces the number of queries executed in IssueReferenceFilter by retrieving the various projects/issues that may be referenced in batches _before_ iterating over all the HTML nodes. A chunk of the logic resides in AbstractReferenceFilter so it can be re-used by other filters in the future. --- .../filter/abstract_reference_filter.rb | 51 +++++++++++++++++- lib/banzai/filter/issue_reference_filter.rb | 31 ++++++++++- .../filter/abstract_link_filter_spec.rb | 52 +++++++++++++++++++ .../filter/issue_reference_filter_spec.rb | 9 ++-- spec/services/git_push_service_spec.rb | 3 +- 5 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 spec/lib/banzai/filter/abstract_link_filter_spec.rb diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index db95d7c908..4815bafe23 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -103,7 +103,7 @@ module Banzai ref_pattern = object_class.reference_pattern link_pattern = object_class.link_reference_pattern - each_node do |node| + nodes.each do |node| if text_node?(node) && ref_pattern replace_text_when_pattern_matches(node, ref_pattern) do |content| object_link_filter(content, ref_pattern) @@ -206,6 +206,55 @@ module Banzai text end + # Returns a Hash containing all object references (e.g. issue IDs) per the + # project they belong to. + def references_per_project + @references_per_project ||= begin + refs = Hash.new { |hash, key| hash[key] = Set.new } + + regex = Regexp.union(object_class.reference_pattern, + object_class.link_reference_pattern) + + nodes.each do |node| + node.to_html.scan(regex) do + project = $~[:project] || current_project_path + + refs[project] << $~[object_sym] + end + end + + refs + end + end + + # Returns a Hash containing referenced projects grouped per their full + # path. + def projects_per_reference + @projects_per_reference ||= begin + hash = {} + refs = Set.new + + references_per_project.each do |project_ref, _| + refs << project_ref + end + + find_projects_for_paths(refs.to_a).each do |project| + hash[project.path_with_namespace] = project + end + + hash + end + end + + # Returns the projects for the given paths. + def find_projects_for_paths(paths) + Project.where_paths_in(paths).includes(:namespace) + end + + def current_project_path + @current_project_path ||= project.path_with_namespace + end + private def project_refs_cache diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb index 2496e70400..2614261f9e 100644 --- a/lib/banzai/filter/issue_reference_filter.rb +++ b/lib/banzai/filter/issue_reference_filter.rb @@ -11,13 +11,40 @@ module Banzai Issue end - def find_object(project, id) - project.get_issue(id) + def find_object(project, iid) + issues_per_project[project][iid] end def url_for_object(issue, project) IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path]) end + + def project_from_ref(ref) + projects_per_reference[ref || current_project_path] + end + + # Returns a Hash containing the issues per Project instance. + def issues_per_project + @issues_per_project ||= begin + hash = Hash.new { |h, k| h[k] = {} } + + projects_per_reference.each do |path, project| + issue_ids = references_per_project[path] + + next unless project.default_issues_tracker? + + project.issues.where(iid: issue_ids.to_a).each do |issue| + hash[project][issue.iid] = issue + end + end + + hash + end + end + + def find_projects_for_paths(paths) + super(paths).includes(:gitlab_issue_tracker_service) + end end end end diff --git a/spec/lib/banzai/filter/abstract_link_filter_spec.rb b/spec/lib/banzai/filter/abstract_link_filter_spec.rb new file mode 100644 index 0000000000..0c55d8e19d --- /dev/null +++ b/spec/lib/banzai/filter/abstract_link_filter_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Banzai::Filter::AbstractReferenceFilter do + let(:project) { create(:empty_project) } + + describe '#references_per_project' do + it 'returns a Hash containing references grouped per project paths' do + doc = Nokogiri::HTML.fragment("#1 #{project.to_reference}#2") + filter = described_class.new(doc, project: project) + + expect(filter).to receive(:object_class).twice.and_return(Issue) + expect(filter).to receive(:object_sym).twice.and_return(:issue) + + refs = filter.references_per_project + + expect(refs).to be_an_instance_of(Hash) + expect(refs[project.to_reference]).to eq(Set.new(%w[1 2])) + end + end + + describe '#projects_per_reference' do + it 'returns a Hash containing projects grouped per project paths' do + doc = Nokogiri::HTML.fragment('') + filter = described_class.new(doc, project: project) + + expect(filter).to receive(:references_per_project). + and_return({ project.path_with_namespace => Set.new(%w[1]) }) + + expect(filter.projects_per_reference). + to eq({ project.path_with_namespace => project }) + end + end + + describe '#find_projects_for_paths' do + it 'returns a list of Projects for a list of paths' do + doc = Nokogiri::HTML.fragment('') + filter = described_class.new(doc, project: project) + + expect(filter.find_projects_for_paths([project.path_with_namespace])). + to eq([project]) + end + end + + describe '#current_project_path' do + it 'returns the path of the current project' do + doc = Nokogiri::HTML.fragment('') + filter = described_class.new(doc, project: project) + + expect(filter.current_project_path).to eq(project.path_with_namespace) + end + end +end diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb index 8e6a264970..25f0bc2092 100644 --- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb @@ -25,7 +25,9 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do let(:reference) { issue.to_reference } it 'ignores valid references when using non-default tracker' do - expect(project).to receive(:get_issue).with(issue.iid).and_return(nil) + expect_any_instance_of(described_class).to receive(:find_object). + with(project, issue.iid). + and_return(nil) exp = act = "Issue #{reference}" expect(reference_filter(act).to_html).to eq exp @@ -107,8 +109,9 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do let(:reference) { issue.to_reference(project) } it 'ignores valid references when cross-reference project uses external tracker' do - expect_any_instance_of(Project).to receive(:get_issue). - with(issue.iid).and_return(nil) + expect_any_instance_of(described_class).to receive(:find_object). + with(project2, issue.iid). + and_return(nil) exp = act = "Issue #{reference}" expect(reference_filter(act).to_html).to eq exp diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 18692f1279..f99ad046f0 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -312,7 +312,8 @@ describe GitPushService, services: true do end it "doesn't close issues when external issue tracker is in use" do - allow(project).to receive(:default_issues_tracker?).and_return(false) + allow_any_instance_of(Project).to receive(:default_issues_tracker?). + and_return(false) # The push still shouldn't create cross-reference notes. expect do From ae6a54f73caaa0d9023d09f0820f3bee1e0cd0d4 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Thu, 16 Jun 2016 09:56:58 +0200 Subject: [PATCH 180/195] Banzai::Filter::ExternalLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/external_link_filter.rb | 13 ++------ .../filter/external_link_filter_spec.rb | 32 +++++++++++++------ 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 39532e8813..b886668d89 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -119,6 +119,7 @@ v 8.8.5 - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to - Banzai::Filter::UploadLinkFilter use XPath instead CSS expressions + - Banzai::Filter::ExternalLinkFilter use XPath instead CSS expressions v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 diff --git a/lib/banzai/filter/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb index f73ecfc941..0a29c547a4 100644 --- a/lib/banzai/filter/external_link_filter.rb +++ b/lib/banzai/filter/external_link_filter.rb @@ -3,17 +3,8 @@ module Banzai # HTML Filter to modify the attributes of external links class ExternalLinkFilter < HTML::Pipeline::Filter def call - doc.search('a').each do |node| - link = node.attr('href') - - next unless link - - # Skip non-HTTP(S) links - next unless link.start_with?('http') - - # Skip internal links - next if link.start_with?(internal_url) - + # Skip non-HTTP(S) links and internal links + doc.xpath("descendant-or-self::a[starts-with(@href, 'http') and not(starts-with(@href, '#{internal_url}'))]").each do |node| node.set_attribute('rel', 'nofollow noreferrer') node.set_attribute('target', '_blank') end diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb index f4c5c621bd..695a5bc6fd 100644 --- a/spec/lib/banzai/filter/external_link_filter_spec.rb +++ b/spec/lib/banzai/filter/external_link_filter_spec.rb @@ -19,19 +19,31 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do expect(filter(act).to_html).to eq exp end - it 'adds rel="nofollow" to external links' do - act = %q(<a href="https://google.com/">Google</a>) - doc = filter(act) + context 'for root links on document' do + let(:doc) { filter %q(<a href="https://google.com/">Google</a>) } - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'nofollow' + it 'adds rel="nofollow" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'nofollow' + end + + it 'adds rel="noreferrer" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noreferrer' + end end - it 'adds rel="noreferrer" to external links' do - act = %q(<a href="https://google.com/">Google</a>) - doc = filter(act) + context 'for nested links on document' do + let(:doc) { filter %q(<p><a href="https://google.com/">Google</a></p>) } - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'noreferrer' + it 'adds rel="nofollow" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'nofollow' + end + + it 'adds rel="noreferrer" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noreferrer' + end end end From 537e8f2105413623c72f3580bc29cac7bb0ec45d Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 16 Jun 2016 13:58:00 +0100 Subject: [PATCH 181/195] Added title attribute to enties in tree view Closes #18353 --- app/views/projects/tree/_blob_item.html.haml | 5 +++-- app/views/projects/tree/_tree_item.html.haml | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml index 2ddc5d504f..a3a4dba3fa 100644 --- a/app/views/projects/tree/_blob_item.html.haml +++ b/app/views/projects/tree/_blob_item.html.haml @@ -1,8 +1,9 @@ %tr{ class: "tree-item #{tree_hex_class(blob_item)}" } %td.tree-item-file-name = tree_icon(type, blob_item.mode, blob_item.name) - %span.str-truncated - = link_to blob_item.name, namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)) + - file_name = blob_item.name + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)), title: file_name do + %span.str-truncated= file_name %td.tree_time_ago.cgray = render 'projects/tree/spinner' %td.hidden-xs.tree_commit diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml index cf65057e70..9577696fc0 100644 --- a/app/views/projects/tree/_tree_item.html.haml +++ b/app/views/projects/tree/_tree_item.html.haml @@ -1,9 +1,9 @@ %tr{ class: "tree-item #{tree_hex_class(tree_item)}" } %td.tree-item-file-name = tree_icon(type, tree_item.mode, tree_item.name) - %span.str-truncated - - path = flatten_tree(tree_item) - = link_to path, namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)) + - path = flatten_tree(tree_item) + = link_to namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)), title: path do + %span.str-truncated= path %td.tree_time_ago.cgray = render 'projects/tree/spinner' %td.hidden-xs.tree_commit From cfbf88f0298aee71e89650dae368800ea7b235d9 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Thu, 16 Jun 2016 08:15:01 -0500 Subject: [PATCH 182/195] Fix indentation scss-lint errors --- 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 78e6f5914a..a7bcb45656 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -8,8 +8,8 @@ &:hover, &.highlight { - fill: lighten($path-color, 25%); - transition: all 0.1s; + fill: lighten($path-color, 25%); + transition: all 0.1s; } } From 5d33af5ee5d13ca35669d7277b6cdd6357e0bca6 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Thu, 16 Jun 2016 16:07:07 +0200 Subject: [PATCH 183/195] Use Git cached counters on project show page Besides when building the repository cache we cache those git counters too --- CHANGELOG | 1 + app/models/repository.rb | 2 +- app/views/projects/show.html.haml | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 03ca4be80d..2741438197 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -110,6 +110,7 @@ v 8.9.0 (unreleased) - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed - Set inverse_of for Project/Service association to reduce the number of queries - Update tanuki logo highlight/loading colors + - Use Git cached counters for branches and tags on project page v 8.8.5 - Import GitHub repositories respecting the API rate limit !4166 diff --git a/app/models/repository.rb b/app/models/repository.rb index e5b277cb19..65d1bad511 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -243,7 +243,7 @@ class Repository end def cache_keys - %i(size branch_names tag_names commit_count + %i(size branch_names tag_names branch_count tag_count commit_count readme version contribution_guide changelog license_blob license_key gitignore) end diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 4afa902b4e..e9ca46a74b 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -23,10 +23,10 @@ #{'Commit'.pluralize(@project.commit_count)} (#{number_with_delimiter(@project.commit_count)}) %li = link_to namespace_project_branches_path(@project.namespace, @project) do - #{'Branch'.pluralize(@repository.branch_names.count)} (#{number_with_delimiter(@repository.branch_names.count)}) + #{'Branch'.pluralize(@repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)}) %li = link_to namespace_project_tags_path(@project.namespace, @project) do - #{'Tag'.pluralize(@repository.tag_names.count)} (#{number_with_delimiter(@repository.tag_names.count)}) + #{'Tag'.pluralize(@repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)}) - if default_project_view != 'readme' && @repository.readme %li From 91253a3afd520fde70ae3f3c7057fccabd54c472 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Thu, 16 Jun 2016 15:56:39 -0700 Subject: [PATCH 184/195] Use gitlab-git 10.2.0 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 1a7af66fac..435bccaf04 100644 --- a/Gemfile +++ b/Gemfile @@ -52,7 +52,7 @@ gem "browser", '~> 2.0.3' # Extracting information from a git repository # Provide access to Gitlab::Git library -gem "gitlab_git", '~> 10.0' +gem "gitlab_git", '~> 10.2' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes diff --git a/Gemfile.lock b/Gemfile.lock index 2b28cb0d91..e5d0f8119d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -277,7 +277,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (10.1.4) + gitlab_git (10.2.0) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -874,7 +874,7 @@ DEPENDENCIES github-markup (~> 1.3.1) gitlab-flowdock-git-hook (~> 1.0.1) gitlab_emoji (~> 0.3.0) - gitlab_git (~> 10.0) + gitlab_git (~> 10.2) gitlab_meta (= 7.0) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.1.0) From dd1f56b5c4aa630d3f84e41c12af666f53e88dea Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:49:34 -0400 Subject: [PATCH 185/195] Customize the Devise `password_change` emails --- app/views/devise/mailer/password_change.html.haml | 10 ++++++++++ app/views/devise/mailer/password_change.text.erb | 7 +++++++ 2 files changed, 17 insertions(+) create mode 100644 app/views/devise/mailer/password_change.html.haml create mode 100644 app/views/devise/mailer/password_change.text.erb diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml new file mode 100644 index 0000000000..3349ee8480 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.haml @@ -0,0 +1,10 @@ +.center + #content + %h2 Hello, #{@resource.name}! + %p + The password for your GitLab account on + #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)} + has successfully been changed. + %p + If you did not initiate this change, please contact your administrator + immediately. diff --git a/app/views/devise/mailer/password_change.text.erb b/app/views/devise/mailer/password_change.text.erb new file mode 100644 index 0000000000..95923d9f8d --- /dev/null +++ b/app/views/devise/mailer/password_change.text.erb @@ -0,0 +1,7 @@ +Hello, <%= @resource.name %>! + +The password for your GitLab account on <%= Gitlab.config.gitlab.url %> +has successfully been changed. + +If you did not initiate this change, please contact your administrator +immediately. From 3a5315d3ceed547de47685ba1a57b31cc8b67c96 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:51:52 -0400 Subject: [PATCH 186/195] Customize the Devise `reset_password_instructions` email --- .../mailer/reset_password_instructions.html.erb | 8 -------- .../mailer/reset_password_instructions.html.haml | 12 ++++++++++++ .../mailer/reset_password_instructions.text.erb | 10 ++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) delete mode 100644 app/views/devise/mailer/reset_password_instructions.html.erb create mode 100644 app/views/devise/mailer/reset_password_instructions.html.haml create mode 100644 app/views/devise/mailer/reset_password_instructions.text.erb diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb deleted file mode 100644 index 23b31da92d..0000000000 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<p>Hello <%= @resource.email %>!</p> - -<p>Someone has requested a link to change your password, and you can do this through the link below.</p> - -<p><%= link_to 'Change your password', edit_password_url(@resource, reset_password_token: @token) %></p> - -<p>If you didn't request this, please ignore this email.</p> -<p>Your password won't change until you access the link above and create a new one.</p> diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml new file mode 100644 index 0000000000..e91c952252 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.haml @@ -0,0 +1,12 @@ +.center + #content + %h2 Hello, #{@resource.name}! + %p + Someone, hopefully you, has requested to reset the password for your + GitLab account on #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}. + %p + If you did not perform this request, you can safely ignore this email. + %p + Otherwise, click the link below to complete the process. + #cta + = link_to('Reset password', edit_password_url(@resource, reset_password_token: @token)) diff --git a/app/views/devise/mailer/reset_password_instructions.text.erb b/app/views/devise/mailer/reset_password_instructions.text.erb new file mode 100644 index 0000000000..116313ee11 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.text.erb @@ -0,0 +1,10 @@ +Hello, <%= @resource.name %>! + +Someone, hopefully you, has requested to reset the password for your GitLab +account on <%= Gitlab.config.gitlab.url %> + +If you did not perform this request, you can safely ignore this email. + +Otherwise, click the link below to complete the process: + +<%= edit_password_url(@resource, reset_password_token: @token) %> From 933cd3478d8930fa5472d509d78bb7dd481d2360 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:53:13 -0400 Subject: [PATCH 187/195] Customize the Devise `unlock_instructions` email --- .../mailer/unlock_instructions.html.haml | 19 +++++++++---------- .../mailer/unlock_instructions.text.erb | 7 +++++++ 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 app/views/devise/mailer/unlock_instructions.text.erb diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml index 52b327e20c..9990d1ccac 100644 --- a/app/views/devise/mailer/unlock_instructions.html.haml +++ b/app/views/devise/mailer/unlock_instructions.html.haml @@ -1,10 +1,9 @@ -%p -Hello #{@resource.name}! - -%p - Your GitLab account has been locked due to an excessive amount of unsuccessful - sign in attempts. Your account will automatically unlock in - = time_ago_in_words(Devise.unlock_in.from_now) - or you may click the link below to unlock now. - -%p= link_to 'Unlock your account', unlock_url(@resource, unlock_token: @token) +.center + #content + %h2 Hello, #{@resource.name}! + %p + Your GitLab account has been locked due to an excessive amount of unsuccessful + sign in attempts. Your account will automatically unlock in #{time_ago_in_words(Devise.unlock_in.from_now)} + or you may click the link below to unlock now. + #cta + = link_to('Unlock account', unlock_url(@resource, unlock_token: @token)) diff --git a/app/views/devise/mailer/unlock_instructions.text.erb b/app/views/devise/mailer/unlock_instructions.text.erb new file mode 100644 index 0000000000..3aea3e2014 --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.text.erb @@ -0,0 +1,7 @@ +Hello, <%= @resource.name %>! + +Your GitLab account has been locked due to an excessive amount of unsuccessful +sign in attempts. Your account will automatically unlock in <%= time_ago_in_words(Devise.unlock_in.from_now) %> +or you may click the link below to unlock now. + +<%= unlock_url(@resource, unlock_token: @token) %> From 7b66dcf65e240723ae6c8772492bbb79cf4a348e Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:53:26 -0400 Subject: [PATCH 188/195] Add previews for all customized Devise emails --- .../mailers/previews/devise_mailer_preview.rb | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/spec/mailers/previews/devise_mailer_preview.rb b/spec/mailers/previews/devise_mailer_preview.rb index dc3062a433..d6588efc48 100644 --- a/spec/mailers/previews/devise_mailer_preview.rb +++ b/spec/mailers/previews/devise_mailer_preview.rb @@ -1,11 +1,30 @@ class DeviseMailerPreview < ActionMailer::Preview def confirmation_instructions_for_signup - user = User.new(name: 'Jane Doe', email: 'signup@example.com') - DeviseMailer.confirmation_instructions(user, 'faketoken', {}) + DeviseMailer.confirmation_instructions(unsaved_user, 'faketoken', {}) end def confirmation_instructions_for_new_email user = User.last + user.unconfirmed_email = 'unconfirmed@example.com' + DeviseMailer.confirmation_instructions(user, 'faketoken', {}) end + + def reset_password_instructions + DeviseMailer.reset_password_instructions(unsaved_user, 'faketoken', {}) + end + + def unlock_instructions + DeviseMailer.unlock_instructions(unsaved_user, 'faketoken', {}) + end + + def password_change + DeviseMailer.password_change(unsaved_user, {}) + end + + private + + def unsaved_user + User.new(name: 'Jane Doe', email: 'jdoe@example.com') + end end From f63bc8222def552700d3f3d30680e34051ece616 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Thu, 9 Jun 2016 22:04:54 -0400 Subject: [PATCH 189/195] Center the header logo for all Devise emails --- CHANGELOG | 1 + app/assets/stylesheets/mailers/devise.scss | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 043eb5a0d1..480c02f06c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,7 @@ v 8.9.0 (unreleased) - Fix an issue where note polling stopped working if a window was in the background during a refresh. - Make EmailsOnPushWorker use Sidekiq mailers queue + - Redesign all Devise emails. !4297 - Fix wiki page events' webhook to point to the wiki repository - Don't show tags for revert and cherry-pick operations - Fix issue todo not remove when leave project !4150 (Long Nguyen) diff --git a/app/assets/stylesheets/mailers/devise.scss b/app/assets/stylesheets/mailers/devise.scss index 28611a5ec8..9495c5b3f3 100644 --- a/app/assets/stylesheets/mailers/devise.scss +++ b/app/assets/stylesheets/mailers/devise.scss @@ -38,6 +38,10 @@ table { margin: 0 auto; text-align: left; width: 600px; + + & > td { + text-align: center; + } } &#body { From 03ba240fddb30800d99bb251347edd67a4fa830f Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Thu, 16 Jun 2016 20:26:29 -0400 Subject: [PATCH 190/195] Update CHANGELOG for !4659 [ci skip] --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 058d4e4cb8..e699515f23 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -69,7 +69,7 @@ v 8.9.0 (unreleased) - Measure queue duration between gitlab-workhorse and Rails - Added Gfm autocomplete for labels - Make Omniauth providers specs to not modify global configuration - - Remove unused JiraIssue class and replace references with ExternalIssue + - Remove unused JiraIssue class and replace references with ExternalIssue. !4659 (Ilan Shamir) - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) - Cache assigned issue and merge request counts in sidebar nav From aef6214c42c6b6abc7f84f9c92f5f9b836157c9a Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 17 Jun 2016 11:43:08 +0200 Subject: [PATCH 191/195] Validate only and except regexp Currently the RegexpError can be raised when processing next stage which leads to 500 in different places of code base. This adds early check that regexps used in only and except are valid. --- CHANGELOG | 1 + lib/ci/gitlab_ci_yaml_processor.rb | 8 +-- .../ci/config/node/validation_helpers.rb | 17 ++++++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 59 ++++++++++++++++++- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f3ac35be81..891a227664 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -59,6 +59,7 @@ v 8.9.0 (unreleased) - Bamboo Service: Fix missing credentials & URL handling when base URL contains a path (Benjamin Schmid) - TeamCity Service: Fix URL handling when base URL contains a path - Todos will display target state if issuable target is 'Closed' or 'Merged' + - Validate only and except regexp - Fix bug when sorting issues by milestone due date and filtering by two or more labels - Add support for using Yubikeys (U2F) for two-factor authentication - Link to blank group icon doesn't throw a 404 anymore diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index a66602f919..325ab795de 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -204,12 +204,12 @@ module Ci raise ValidationError, "#{name} job: tags parameter should be an array of strings" end - if job[:only] && !validate_array_of_strings(job[:only]) - raise ValidationError, "#{name} job: only parameter should be an array of strings" + if job[:only] && !validate_array_of_strings_or_regexps(job[:only]) + raise ValidationError, "#{name} job: only parameter should be an array of strings or regexps" end - if job[:except] && !validate_array_of_strings(job[:except]) - raise ValidationError, "#{name} job: except parameter should be an array of strings" + if job[:except] && !validate_array_of_strings_or_regexps(job[:except]) + raise ValidationError, "#{name} job: except parameter should be an array of strings or regexps" end if job[:allow_failure] && !validate_boolean(job[:allow_failure]) diff --git a/lib/gitlab/ci/config/node/validation_helpers.rb b/lib/gitlab/ci/config/node/validation_helpers.rb index 3900fc8939..72f648975d 100644 --- a/lib/gitlab/ci/config/node/validation_helpers.rb +++ b/lib/gitlab/ci/config/node/validation_helpers.rb @@ -15,6 +15,10 @@ module Gitlab values.is_a?(Array) && values.all? { |value| validate_string(value) } end + def validate_array_of_strings_or_regexps(values) + values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) } + end + def validate_variables(variables) variables.is_a?(Hash) && variables.all? { |key, value| validate_string(key) && validate_string(value) } @@ -24,6 +28,19 @@ module Gitlab value.is_a?(String) || value.is_a?(Symbol) end + def validate_string_or_regexp(value) + return true if value.is_a?(Symbol) + return false unless value.is_a?(String) + + if value.first == '/' && value.last == '/' + Regexp.new(value[1...-1]) + else + true + end + rescue RegexpError + false + end + def validate_environment(value) value.is_a?(String) && value =~ Gitlab::Regex.environment_name_regex end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 143e2e6d23..b0b03146d0 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -157,6 +157,35 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "deploy").size).to eq(1) expect(config_processor.builds_for_stage_and_ref("deploy", "master").size).to eq(1) end + + context 'for invalid value' do + let(:config) { { rspec: { script: "rspec", type: "test", only: only } } } + let(:processor) { GitlabCiYamlProcessor.new(YAML.dump(config)) } + + shared_examples 'raises an error' do + it do + expect { processor }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'rspec job: only parameter should be an array of strings or regexps') + end + end + + context 'when it is integer' do + let(:only) { 1 } + + it_behaves_like 'raises an error' + end + + context 'when it is an array of integers' do + let(:only) { [1, 1] } + + it_behaves_like 'raises an error' + end + + context 'when it is invalid regex' do + let(:only) { ["/*invalid/"] } + + it_behaves_like 'raises an error' + end + end end describe :except do @@ -284,8 +313,36 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "test").size).to eq(0) expect(config_processor.builds_for_stage_and_ref("deploy", "master").size).to eq(0) end - end + context 'for invalid value' do + let(:config) { { rspec: { script: "rspec", except: except } } } + let(:processor) { GitlabCiYamlProcessor.new(YAML.dump(config)) } + + shared_examples 'raises an error' do + it do + expect { processor }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'rspec job: except parameter should be an array of strings or regexps') + end + end + + context 'when it is integer' do + let(:except) { 1 } + + it_behaves_like 'raises an error' + end + + context 'when it is an array of integers' do + let(:except) { [1, 1] } + + it_behaves_like 'raises an error' + end + + context 'when it is invalid regex' do + let(:except) { ["/*invalid/"] } + + it_behaves_like 'raises an error' + end + end + end end describe "Scripts handling" do From d63673d6df703664aa95909c723df08449c573b5 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 17 Jun 2016 12:58:26 +0200 Subject: [PATCH 192/195] Make sure that artifacts_file is nullified after removing artifacts --- app/models/ci/build.rb | 1 + spec/workers/expire_build_artifacts_worker_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 764d8e4e13..d618c84e98 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -341,6 +341,7 @@ module Ci def erase_artifacts! remove_artifacts_file! remove_artifacts_metadata! + save end def erase(opts = {}) diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index e3827cae9a..7d6668920c 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -20,6 +20,10 @@ describe ExpireBuildArtifactsWorker do it 'does remove files' do expect(build.reload.artifacts_file.exists?).to be_falsey end + + it 'does nullify artifacts_file column' do + expect(build.reload.artifacts_file_identifier).to be_nil + end end context 'with not yet expired artifacts' do @@ -32,6 +36,10 @@ describe ExpireBuildArtifactsWorker do it 'does not remove files' do expect(build.reload.artifacts_file.exists?).to be_truthy end + + it 'does not nullify artifacts_file column' do + expect(build.reload.artifacts_file_identifier).not_to be_nil + end end context 'without expire date' do @@ -44,6 +52,10 @@ describe ExpireBuildArtifactsWorker do it 'does not remove files' do expect(build.reload.artifacts_file.exists?).to be_truthy end + + it 'does not nullify artifacts_file column' do + expect(build.reload.artifacts_file_identifier).not_to be_nil + end end context 'for expired artifacts' do From eaa91cbe12a6919c865615f64c6e61e779c5f1ad Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 17 Jun 2016 14:14:55 +0200 Subject: [PATCH 193/195] Fix error when CI job variables not specified --- lib/ci/gitlab_ci_yaml_processor.rb | 2 +- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 54 ++++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index a66602f919..ea296d05b2 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -54,7 +54,7 @@ module Ci job = @jobs[name.to_sym] return [] unless job - job.fetch(:variables, []) + job[:variables] || [] end private diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 143e2e6d23..1c775c182f 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -287,13 +287,13 @@ module Ci end end - + describe "Scripts handling" do let(:config_data) { YAML.dump(config) } let(:config_processor) { GitlabCiYamlProcessor.new(config_data, path) } - + subject { config_processor.builds_for_stage_and_ref("test", "master").first } - + describe "before_script" do context "in global context" do let(:config) do @@ -302,12 +302,12 @@ module Ci test: { script: ["script"] } } end - + it "return commands with scripts concencaced" do expect(subject[:commands]).to eq("global script\nscript") end end - + context "overwritten in local context" do let(:config) do { @@ -465,19 +465,41 @@ module Ci end context 'when syntax is incorrect' do - it 'raises error' do - variables = [:KEY1, 'value1', :KEY2, 'value2'] + context 'when variables defined but invalid' do + it 'raises error' do + variables = [:KEY1, 'value1', :KEY2, 'value2'] - config = YAML.dump( - { before_script: ['pwd'], - rspec: { - variables: variables, - script: 'rspec' } - }) + config = YAML.dump( + { before_script: ['pwd'], + rspec: { + variables: variables, + script: 'rspec' } + }) - expect { GitlabCiYamlProcessor.new(config, path) } - .to raise_error(GitlabCiYamlProcessor::ValidationError, - /job: variables should be a map/) + expect { GitlabCiYamlProcessor.new(config, path) } + .to raise_error(GitlabCiYamlProcessor::ValidationError, + /job: variables should be a map/) + end + end + + context 'when variables key defined but value not specified' do + it 'returns empty array' do + config = YAML.dump( + { before_script: ['pwd'], + rspec: { + variables: nil, + script: 'rspec' } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + + ## + # TODO, in next version of CI configuration processor this + # should be invalid configuration, see #18775 and #15060 + # + expect(config_processor.job_variables(:rspec)) + .to be_an_instance_of(Array).and be_empty + end end end end From 6511b973e6a5d6cbdf8bab3b63be2692d95496de Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 17 Jun 2016 14:29:44 +0200 Subject: [PATCH 194/195] Add Changelog entry for CI job variables config fix --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index f3ac35be81..a610b52844 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.9.0 (unreleased) + - Fix error when CI job variables key specified but not defined - Fix pipeline status when there are no builds in pipeline - Fix Error 500 when using closes_issues API with an external issue tracker - Add more information into RSS feed for issues (Alexander Matyushentsev) From 673ae917f57655841d2a3607594dac27b6c2f0e8 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 17 Jun 2016 14:09:00 +0100 Subject: [PATCH 195/195] Fixed styling of commit box in new MR Closes #18781 --- app/assets/stylesheets/pages/merge_requests.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 53bff508c7..e67271adfb 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -244,6 +244,10 @@ .panel-footer { padding: 5px 10px; + + .btn { + min-width: auto; + } } .commit { @@ -252,9 +256,7 @@ } .avatar { - width: 20px; - height: 20px; - margin-right: 5px; + margin-left: 0; } .commit-row-info {