From 3a609038748055a27c7e01cf4b55d8249709c9cc Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 13:06:44 +0530 Subject: Allow creating Personal Access Tokens through the website. --- .../profiles/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 + ...20160415062917_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 00000000000..dbf06cb4c6d --- /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 00000000000..29f2275475f --- /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 b6f405c6981..7ac30d21cb7 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 00000000000..89da41a75ef --- /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 00000000000..05eed3c5c3c --- /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 d664434e1a6..d1be826d2a1 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 00000000000..42a41349a0c --- /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 42457d92353..05c97003971 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 -- cgit v1.2.1 From e8314ccca5ff1cd9cf2b1d1aeccd699598b384a5 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 19:37:21 +0530 Subject: 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 cc1004f8005..537678863cb 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 5bbf721321d..00000000000 --- 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 00000000000..8c7bb2c8cdf --- /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 00000000000..c37064d49ab --- /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 353c4ddebf8..495f5792b08 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 40b24c125b5..628569d3e00 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 -- cgit v1.2.1 From 5fb44192964c962000f8c8708d823931ee6a6d8e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 19:48:47 +0530 Subject: 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 8c7bb2c8cdf..f11c9725f3f 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 current_user + def find_user_by_private_token private_token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s - @current_user ||= (User.find_by(authentication_token: private_token) || doorkeeper_guard) + 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 + @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 -- cgit v1.2.1 From e2a4051cc3f4192849d7571bf83b0d9a7b2cbd4e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 19:55:38 +0530 Subject: 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 f11c9725f3f..e1d7ac83ff6 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 -- cgit v1.2.1 From 6d76f14f54eb1af0e5c29eff1b8f5e70d2264ffd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 15 Apr 2016 20:54:20 +0530 Subject: Allow revoking personal access tokens. --- app/assets/stylesheets/pages/profile.scss | 3 +++ .../profiles/personal_access_tokens_controller.rb | 16 +++++++++++++++- app/models/personal_access_token.rb | 7 +++++++ .../profiles/personal_access_tokens/index.html.haml | 9 ++++++++- config/locales/en.yml | 4 ++++ config/routes.rb | 6 +++++- ...44643_add_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 01f98479623..5a4d0a5c8b0 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 dbf06cb4c6d..59de0b26eee 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 29f2275475f..e5f1f9749f8 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 05eed3c5c3c..02d15269c85 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 cedb5e207bd..b5b8c4467b0 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 d1be826d2a1..4e4666762f8 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 00000000000..8eecdfc5a1c --- /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 e1d7ac83ff6..666bf3ffa16 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 -- cgit v1.2.1 From 1541d1de18c3e7707ce1289f882b4c1262ec8c71 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 18 Apr 2016 14:47:38 +0530 Subject: Rename `api_helpers_spec` to `api_authentication_spec` - And fix all tests. --- spec/requests/api/api_authentication_spec.rb | 175 +++++++++++++++++++++++++++ spec/requests/api/api_helpers_spec.rb | 173 -------------------------- 2 files changed, 175 insertions(+), 173 deletions(-) create mode 100644 spec/requests/api/api_authentication_spec.rb delete mode 100644 spec/requests/api/api_helpers_spec.rb diff --git a/spec/requests/api/api_authentication_spec.rb b/spec/requests/api/api_authentication_spec.rb new file mode 100644 index 00000000000..8bed3bb119b --- /dev/null +++ b/spec/requests/api/api_authentication_spec.rb @@ -0,0 +1,175 @@ +require 'spec_helper' + +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) } + + let(:params) { {} } + let(:env) { {} } + + 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 + 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 + end + + def clear_env + env.delete(API::Helpers::Authentication::PRIVATE_TOKEN_HEADER) + env.delete(API::Helpers::Authentication::SUDO_HEADER) + end + + def clear_param + params.delete(API::Helpers::Authentication::PRIVATE_TOKEN_PARAM) + params.delete(API::Helpers::Authentication::SUDO_PARAM) + end + + def error!(message, status) + raise Exception + 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 + 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 + + it "should change current user to sudo when admin" do + set_env(admin, user.id) + expect(current_user).to eq(user) + set_param(admin, user.id) + expect(current_user).to eq(user) + set_env(admin, user.username) + expect(current_user).to eq(user) + set_param(admin, user.username) + expect(current_user).to eq(user) + end + + it "should throw an error when the current user is not an admin and attempting to sudo" do + set_env(user, admin.id) + expect { current_user }.to raise_error(Exception) + set_param(user, admin.id) + expect { current_user }.to raise_error(Exception) + set_env(user, admin.username) + expect { current_user }.to raise_error(Exception) + set_param(user, admin.username) + expect { current_user }.to raise_error(Exception) + end + + it "should throw an error when the user cannot be found for a given id" do + id = user.id + admin.id + expect(user.id).not_to eq(id) + expect(admin.id).not_to eq(id) + set_env(admin, id) + expect { current_user }.to raise_error(Exception) + + set_param(admin, id) + expect { current_user }.to raise_error(Exception) + end + + it "should throw an error when the user cannot be found for a given username" do + username = "#{user.username}#{admin.username}" + expect(user.username).not_to eq(username) + expect(admin.username).not_to eq(username) + set_env(admin, username) + expect { current_user }.to raise_error(Exception) + + set_param(admin, username) + expect { current_user }.to raise_error(Exception) + end + + it "should handle sudo's to oneself" do + set_env(admin, admin.id) + expect(current_user).to eq(admin) + set_param(admin, admin.id) + expect(current_user).to eq(admin) + set_env(admin, admin.username) + expect(current_user).to eq(admin) + set_param(admin, admin.username) + expect(current_user).to eq(admin) + end + + it "should handle multiple sudo's to oneself" do + set_env(admin, user.id) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + set_env(admin, user.username) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + + set_param(admin, user.id) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + set_param(admin, user.username) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + end + + it "should handle multiple sudo's to oneself using string ids" do + set_env(admin, user.id.to_s) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + + set_param(admin, user.id.to_s) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + end + end + + describe '.sudo_identifier' do + it "should return integers when input is an int" do + set_env(admin, '123') + expect(sudo_identifier).to eq(123) + set_env(admin, '0001234567890') + expect(sudo_identifier).to eq(1234567890) + + set_param(admin, '123') + expect(sudo_identifier).to eq(123) + set_param(admin, '0001234567890') + expect(sudo_identifier).to eq(1234567890) + end + + it "should return string when input is an is not an int" do + set_env(admin, '12.30') + expect(sudo_identifier).to eq("12.30") + set_env(admin, 'hello') + expect(sudo_identifier).to eq('hello') + set_env(admin, ' 123') + expect(sudo_identifier).to eq(' 123') + + set_param(admin, '12.30') + expect(sudo_identifier).to eq("12.30") + set_param(admin, 'hello') + expect(sudo_identifier).to eq('hello') + set_param(admin, ' 123') + expect(sudo_identifier).to eq(' 123') + end + end +end diff --git a/spec/requests/api/api_helpers_spec.rb b/spec/requests/api/api_helpers_spec.rb deleted file mode 100644 index 0c19094ec54..00000000000 --- a/spec/requests/api/api_helpers_spec.rb +++ /dev/null @@ -1,173 +0,0 @@ -require 'spec_helper' - -describe API, api: true do - include API::Helpers - include ApiHelpers - let(:user) { create(:user) } - let(:admin) { create(:admin) } - let(:key) { create(:key, user: user) } - - let(:params) { {} } - let(:env) { {} } - - 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 - 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 - end - - def clear_env - env.delete(API::Helpers::PRIVATE_TOKEN_HEADER) - env.delete(API::Helpers::SUDO_HEADER) - end - - def clear_param - params.delete(API::Helpers::PRIVATE_TOKEN_PARAM) - params.delete(API::Helpers::SUDO_PARAM) - end - - def error!(message, status) - raise Exception - end - - describe ".current_user" do - it "should return nil for an invalid token" do - 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::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 - expect(current_user).to eq(user) - clear_env - params[API::Helpers::PRIVATE_TOKEN_PARAM] = user.private_token - expect(current_user).to eq(user) - end - - it "should change current user to sudo when admin" do - set_env(admin, user.id) - expect(current_user).to eq(user) - set_param(admin, user.id) - expect(current_user).to eq(user) - set_env(admin, user.username) - expect(current_user).to eq(user) - set_param(admin, user.username) - expect(current_user).to eq(user) - end - - it "should throw an error when the current user is not an admin and attempting to sudo" do - set_env(user, admin.id) - expect { current_user }.to raise_error(Exception) - set_param(user, admin.id) - expect { current_user }.to raise_error(Exception) - set_env(user, admin.username) - expect { current_user }.to raise_error(Exception) - set_param(user, admin.username) - expect { current_user }.to raise_error(Exception) - end - - it "should throw an error when the user cannot be found for a given id" do - id = user.id + admin.id - expect(user.id).not_to eq(id) - expect(admin.id).not_to eq(id) - set_env(admin, id) - expect { current_user }.to raise_error(Exception) - - set_param(admin, id) - expect { current_user }.to raise_error(Exception) - end - - it "should throw an error when the user cannot be found for a given username" do - username = "#{user.username}#{admin.username}" - expect(user.username).not_to eq(username) - expect(admin.username).not_to eq(username) - set_env(admin, username) - expect { current_user }.to raise_error(Exception) - - set_param(admin, username) - expect { current_user }.to raise_error(Exception) - end - - it "should handle sudo's to oneself" do - set_env(admin, admin.id) - expect(current_user).to eq(admin) - set_param(admin, admin.id) - expect(current_user).to eq(admin) - set_env(admin, admin.username) - expect(current_user).to eq(admin) - set_param(admin, admin.username) - expect(current_user).to eq(admin) - end - - it "should handle multiple sudo's to oneself" do - set_env(admin, user.id) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - set_env(admin, user.username) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - - set_param(admin, user.id) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - set_param(admin, user.username) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - end - - it "should handle multiple sudo's to oneself using string ids" do - set_env(admin, user.id.to_s) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - - set_param(admin, user.id.to_s) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - end - end - - describe '.sudo_identifier' do - it "should return integers when input is an int" do - set_env(admin, '123') - expect(sudo_identifier).to eq(123) - set_env(admin, '0001234567890') - expect(sudo_identifier).to eq(1234567890) - - set_param(admin, '123') - expect(sudo_identifier).to eq(123) - set_param(admin, '0001234567890') - expect(sudo_identifier).to eq(1234567890) - end - - it "should return string when input is an is not an int" do - set_env(admin, '12.30') - expect(sudo_identifier).to eq("12.30") - set_env(admin, 'hello') - expect(sudo_identifier).to eq('hello') - set_env(admin, ' 123') - expect(sudo_identifier).to eq(' 123') - - set_param(admin, '12.30') - expect(sudo_identifier).to eq("12.30") - set_param(admin, 'hello') - expect(sudo_identifier).to eq('hello') - set_param(admin, ' 123') - expect(sudo_identifier).to eq(' 123') - end - end -end -- cgit v1.2.1 From e5cf527f279964a8952de544526e8def226b98d7 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 18 Apr 2016 15:48:54 +0530 Subject: Allow expiration of personal access tokens. --- app/assets/stylesheets/pages/profile.scss | 4 ++ .../profiles/personal_access_tokens_controller.rb | 2 +- app/models/personal_access_token.rb | 2 +- .../personal_access_tokens/index.html.haml | 20 +++++- ..._column_expires_at_to_personal_access_tokens.rb | 5 ++ spec/factories/personal_access_tokens.rb | 9 +++ spec/requests/api/api_authentication_spec.rb | 72 +++++++++++++++++----- 7 files changed, 94 insertions(+), 20 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 5a4d0a5c8b0..8c62c97215f 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 59de0b26eee..d01afbfe119 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 e5f1f9749f8..dd64374481f 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 02d15269c85..f3d5f07cdd3 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 00000000000..9d99ebeadf5 --- /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 00000000000..da4c72bcb5b --- /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 8bed3bb119b..0416e57c68b 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 - - 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) + describe "when authenticating using a user's personal access tokens" 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' + 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 -- cgit v1.2.1 From 41b4e119e9b076cc0f36bd31cbb42f87e5ecb08f Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 14:01:21 +0530 Subject: Add an entry for Personal Access Tokens in the sidebar. --- app/controllers/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 d01afbfe119..a5804cc0d73 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 d730840d63a..297f3e4063a 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') -- cgit v1.2.1 From fb2da6795c8503db5fed0f856760289e87ea9419 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 15:28:35 +0530 Subject: 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 ++ .../profiles/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 8c62c97215f..85d9173afb3 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 a5804cc0d73..5a6026f58cb 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 dd64374481f..fff3f76fb93 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 f3d5f07cdd3..77726d34fbc 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({ -- cgit v1.2.1 From ade40fdcd2a4ee879bd2aba939cffaff39c65228 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 16:22:15 +0530 Subject: 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 ++++++- spec/controllers/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 1c53b0b21a3..590f9383f7f 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 186239d3096..1ec51465cd3 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 -- cgit v1.2.1 From 051324e12a7384b15aaa68f53dd43ad0b3c67812 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 16:24:33 +0530 Subject: 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 590f9383f7f..2b2726c048c 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 -- cgit v1.2.1 From abd8eccae7d0566ed6966c5f681d4f7c07bfb016 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 19 Apr 2016 16:46:03 +0530 Subject: 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 89da41a75ef..00000000000 --- 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 -- cgit v1.2.1 From 611f3ad2683a1103ef3c2af244a10ac9f3ae6734 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 11:14:08 +0530 Subject: 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 666bf3ffa16..4109c97ed04 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 c37064d49ab..05cda8b84be 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 -- cgit v1.2.1 From 17f2fc10e6765d328b9c34a45815e183cca50466 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 11:57:45 +0530 Subject: 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 5a6026f58cb..7fbf343edbd 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 77726d34fbc..72e67df4337 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' -- cgit v1.2.1 From 25aefde62b0ac57d93ff3a6ddeef3277e40cc7bd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 11:58:48 +0530 Subject: 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 72e67df4337..e61fa69af54 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 00000000000..ce972776ffe --- /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 -- cgit v1.2.1 From 4e7acd88dc02e545ba696a2b4fd8c16bd6df31d5 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 12:21:14 +0530 Subject: 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. --- app/views/profiles/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 e61fa69af54..af34eb389df 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); + } + }); -- cgit v1.2.1 From c382bd266d256cdbd732be928ecb6a532bfa8ddd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 20 Apr 2016 14:20:24 +0530 Subject: Improve performance of the personal access tokens page. --- app/views/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 af34eb389df..02800c37917 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 -- cgit v1.2.1 From 4076bce5d52b8a11eda48c36011666ea344ba3b7 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 22 Apr 2016 12:36:56 +0530 Subject: Merge the "personal access token"-related migrations. --- db/migrate/20160415062917_create_personal_access_tokens.rb | 2 ++ .../20160415144643_add_column_revoked_to_personal_access_tokens.rb | 5 ----- ...20160418085954_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 42a41349a0c..2c9f773e308 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 8eecdfc5a1c..00000000000 --- 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 9d99ebeadf5..00000000000 --- 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 -- cgit v1.2.1 From fc4bce755d19d570c4a00241048517c38aa839b3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 22 Apr 2016 14:03:11 +0530 Subject: 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 7fbf343edbd..af6def25e7f 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 fff3f76fb93..c7c3932ba40 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 4109c97ed04..4330c580276 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 -- cgit v1.2.1 From b22a47c62e076acddd254e2d659f38261085bf01 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 25 Apr 2016 09:00:20 +0530 Subject: 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 +- spec/requests/api/api_authentication_spec.rb | 215 --------------- spec/requests/api/api_helpers_spec.rb | 215 +++++++++++++++ spec/requests/api/users_spec.rb | 2 +- 8 files changed, 615 insertions(+), 625 deletions(-) create mode 100644 lib/api/helpers.rb delete mode 100644 lib/api/helpers/authentication.rb delete mode 100644 lib/api/helpers/core.rb delete mode 100644 spec/requests/api/api_authentication_spec.rb create mode 100644 spec/requests/api/api_helpers_spec.rb diff --git a/lib/api/api.rb b/lib/api/api.rb index 537678863cb..cc1004f8005 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 00000000000..fb30ef3e252 --- /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 4330c580276..00000000000 --- 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 05cda8b84be..00000000000 --- 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 495f5792b08..353c4ddebf8 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_authentication_spec.rb deleted file mode 100644 index 0416e57c68b..00000000000 --- a/spec/requests/api/api_authentication_spec.rb +++ /dev/null @@ -1,215 +0,0 @@ -require 'spec_helper' - -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) } - - let(:params) { {} } - let(:env) { {} } - - 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 - 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 - end - - def clear_env - env.delete(API::Helpers::Authentication::PRIVATE_TOKEN_HEADER) - env.delete(API::Helpers::Authentication::SUDO_HEADER) - end - - def clear_param - params.delete(API::Helpers::Authentication::PRIVATE_TOKEN_PARAM) - params.delete(API::Helpers::Authentication::SUDO_PARAM) - end - - def error!(message, status) - raise Exception - end - - 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' - 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 - - describe "when authenticating using a user's personal access tokens" 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' - 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 - set_env(admin, user.id) - expect(current_user).to eq(user) - set_param(admin, user.id) - expect(current_user).to eq(user) - set_env(admin, user.username) - expect(current_user).to eq(user) - set_param(admin, user.username) - expect(current_user).to eq(user) - end - - it "should throw an error when the current user is not an admin and attempting to sudo" do - set_env(user, admin.id) - expect { current_user }.to raise_error(Exception) - set_param(user, admin.id) - expect { current_user }.to raise_error(Exception) - set_env(user, admin.username) - expect { current_user }.to raise_error(Exception) - set_param(user, admin.username) - expect { current_user }.to raise_error(Exception) - end - - it "should throw an error when the user cannot be found for a given id" do - id = user.id + admin.id - expect(user.id).not_to eq(id) - expect(admin.id).not_to eq(id) - set_env(admin, id) - expect { current_user }.to raise_error(Exception) - - set_param(admin, id) - expect { current_user }.to raise_error(Exception) - end - - it "should throw an error when the user cannot be found for a given username" do - username = "#{user.username}#{admin.username}" - expect(user.username).not_to eq(username) - expect(admin.username).not_to eq(username) - set_env(admin, username) - expect { current_user }.to raise_error(Exception) - - set_param(admin, username) - expect { current_user }.to raise_error(Exception) - end - - it "should handle sudo's to oneself" do - set_env(admin, admin.id) - expect(current_user).to eq(admin) - set_param(admin, admin.id) - expect(current_user).to eq(admin) - set_env(admin, admin.username) - expect(current_user).to eq(admin) - set_param(admin, admin.username) - expect(current_user).to eq(admin) - end - - it "should handle multiple sudo's to oneself" do - set_env(admin, user.id) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - set_env(admin, user.username) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - - set_param(admin, user.id) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - set_param(admin, user.username) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - end - - it "should handle multiple sudo's to oneself using string ids" do - set_env(admin, user.id.to_s) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - - set_param(admin, user.id.to_s) - expect(current_user).to eq(user) - expect(current_user).to eq(user) - end - end - - describe '.sudo_identifier' do - it "should return integers when input is an int" do - set_env(admin, '123') - expect(sudo_identifier).to eq(123) - set_env(admin, '0001234567890') - expect(sudo_identifier).to eq(1234567890) - - set_param(admin, '123') - expect(sudo_identifier).to eq(123) - set_param(admin, '0001234567890') - expect(sudo_identifier).to eq(1234567890) - end - - it "should return string when input is an is not an int" do - set_env(admin, '12.30') - expect(sudo_identifier).to eq("12.30") - set_env(admin, 'hello') - expect(sudo_identifier).to eq('hello') - set_env(admin, ' 123') - expect(sudo_identifier).to eq(' 123') - - set_param(admin, '12.30') - expect(sudo_identifier).to eq("12.30") - set_param(admin, 'hello') - expect(sudo_identifier).to eq('hello') - set_param(admin, ' 123') - expect(sudo_identifier).to eq(' 123') - end - end -end diff --git a/spec/requests/api/api_helpers_spec.rb b/spec/requests/api/api_helpers_spec.rb new file mode 100644 index 00000000000..06997147b09 --- /dev/null +++ b/spec/requests/api/api_helpers_spec.rb @@ -0,0 +1,215 @@ +require 'spec_helper' + +describe API::Helpers, api: true do + + include API::Helpers + include ApiHelpers + + let(:user) { create(:user) } + let(:admin) { create(:admin) } + let(:key) { create(:key, user: user) } + + let(:params) { {} } + let(:env) { {} } + + 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 + 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 + end + + def clear_env + env.delete(API::Helpers::PRIVATE_TOKEN_HEADER) + env.delete(API::Helpers::SUDO_HEADER) + end + + def clear_param + params.delete(API::Helpers::PRIVATE_TOKEN_PARAM) + params.delete(API::Helpers::SUDO_PARAM) + end + + def error!(message, status) + raise Exception + end + + 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::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 + 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 + expect(current_user).to eq(user) + clear_env + params[API::Helpers::PRIVATE_TOKEN_PARAM] = user.private_token + expect(current_user).to eq(user) + end + end + + describe "when authenticating using a user's personal access tokens" 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' + 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 + 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 + expect(current_user).to eq(user) + clear_env + 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::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::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 + set_env(admin, user.id) + expect(current_user).to eq(user) + set_param(admin, user.id) + expect(current_user).to eq(user) + set_env(admin, user.username) + expect(current_user).to eq(user) + set_param(admin, user.username) + expect(current_user).to eq(user) + end + + it "should throw an error when the current user is not an admin and attempting to sudo" do + set_env(user, admin.id) + expect { current_user }.to raise_error(Exception) + set_param(user, admin.id) + expect { current_user }.to raise_error(Exception) + set_env(user, admin.username) + expect { current_user }.to raise_error(Exception) + set_param(user, admin.username) + expect { current_user }.to raise_error(Exception) + end + + it "should throw an error when the user cannot be found for a given id" do + id = user.id + admin.id + expect(user.id).not_to eq(id) + expect(admin.id).not_to eq(id) + set_env(admin, id) + expect { current_user }.to raise_error(Exception) + + set_param(admin, id) + expect { current_user }.to raise_error(Exception) + end + + it "should throw an error when the user cannot be found for a given username" do + username = "#{user.username}#{admin.username}" + expect(user.username).not_to eq(username) + expect(admin.username).not_to eq(username) + set_env(admin, username) + expect { current_user }.to raise_error(Exception) + + set_param(admin, username) + expect { current_user }.to raise_error(Exception) + end + + it "should handle sudo's to oneself" do + set_env(admin, admin.id) + expect(current_user).to eq(admin) + set_param(admin, admin.id) + expect(current_user).to eq(admin) + set_env(admin, admin.username) + expect(current_user).to eq(admin) + set_param(admin, admin.username) + expect(current_user).to eq(admin) + end + + it "should handle multiple sudo's to oneself" do + set_env(admin, user.id) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + set_env(admin, user.username) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + + set_param(admin, user.id) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + set_param(admin, user.username) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + end + + it "should handle multiple sudo's to oneself using string ids" do + set_env(admin, user.id.to_s) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + + set_param(admin, user.id.to_s) + expect(current_user).to eq(user) + expect(current_user).to eq(user) + end + end + + describe '.sudo_identifier' do + it "should return integers when input is an int" do + set_env(admin, '123') + expect(sudo_identifier).to eq(123) + set_env(admin, '0001234567890') + expect(sudo_identifier).to eq(1234567890) + + set_param(admin, '123') + expect(sudo_identifier).to eq(123) + set_param(admin, '0001234567890') + expect(sudo_identifier).to eq(1234567890) + end + + it "should return string when input is an is not an int" do + set_env(admin, '12.30') + expect(sudo_identifier).to eq("12.30") + set_env(admin, 'hello') + expect(sudo_identifier).to eq('hello') + set_env(admin, ' 123') + expect(sudo_identifier).to eq(' 123') + + set_param(admin, '12.30') + expect(sudo_identifier).to eq("12.30") + set_param(admin, 'hello') + expect(sudo_identifier).to eq('hello') + set_param(admin, ' 123') + expect(sudo_identifier).to eq(' 123') + end + end +end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 628569d3e00..40b24c125b5 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 -- cgit v1.2.1 From fe5eca8b38e28ad3c77d3d71297cca110306cab9 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 25 Apr 2016 09:19:25 +0530 Subject: Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 91fdfe6216f..b3ea047475e 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 -- cgit v1.2.1 From bafbf22c6ab613d25287d7119d7e30770c531fdb Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 25 Apr 2016 14:30:59 +0530 Subject: 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 ++++- .../profiles/personal_access_tokens/index.html.haml | 18 ++++-------------- spec/controllers/application_controller_spec.rb | 6 ------ spec/features/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 2b2726c048c..eb5ffc44c3b 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 c7c3932ba40..c4b095e0c04 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 02800c37917..f7482d2c87d 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 1ec51465cd3..e8bdbf1afb7 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 ce972776ffe..e9fbeefae75 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 00000000000..3e80a48175a --- /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 -- cgit v1.2.1 From 2768e99ac386598480a7ac5490deee644125dfa1 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 28 Apr 2016 17:18:49 +0530 Subject: Add documentation for personal access tokens. --- doc/api/README.md | 61 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/doc/api/README.md b/doc/api/README.md index ff039f1886f..0e9dc7acfed 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,42 +49,56 @@ returned with status code `401`: } ``` -API requests should be prefixed with `api` and the API version. The API version -is defined in [`lib/api.rb`][lib-api-url]. +### Private Tokens -Example of a valid API request: +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`). -```shell -GET https://gitlab.example.com/api/v3/projects?private_token=9koXpg98eAheJpvBs5tK -``` +### OAuth 2 Tokens -Example of a valid API request using cURL and authentication via header: +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 --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects" +curl -H "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v3/projects ``` -The API uses JSON to serialize data. You don't need to specify `.json` at the -end of an API URL. +Read more about [GitLab as an OAuth2 client](oauth2.md). + +### Personal Access Tokens -## Authentication with OAuth2 token +> **Note:** This feature was [introduced][ce-3749] in GitLab 8.8 -Instead of the `private_token` you can transmit the OAuth2 access token as a -header or as a parameter. +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. -Example of OAuth2 token as a parameter: +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]. + +Example of a valid API request: ```shell -curl https://gitlab.example.com/api/v3/user?access_token=OAUTH-TOKEN +GET https://gitlab.example.com/api/v3/projects?private_token=9koXpg98eAheJpvBs5tK ``` -Example of OAuth2 token as a header: +Example of a valid API request using cURL and authentication via header: ```shell -curl -H "Authorization: Bearer OAUTH-TOKEN" https://example.com/api/v3/user +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects" ``` -Read more about [GitLab as an OAuth2 client](oauth2.md). +The API uses JSON to serialize data. You don't need to specify `.json` at the +end of an API URL. ## Status codes @@ -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 -- cgit v1.2.1 From 14964308dc4ca0d62dd12231529abbcfe940f603 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Tue, 3 May 2016 12:07:06 +0100 Subject: add gfm autocomplete for labels --- app/assets/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 61e3f811e73..6d3f4ec9753 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 3768efe142a..4d7a3bfe642 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 ba50305dbd5..826b10899d0 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 -- cgit v1.2.1 From 68da2ac9a0fa6f33b46f0bfbc849043605c2d878 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Tue, 3 May 2016 12:09:13 +0100 Subject: add entry to CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index b6527780bbf..670f7fa3abd 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 -- cgit v1.2.1 From d915e7d5cad99b8971e65d30accc8bc7a05fecbc Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 11 May 2016 10:16:23 +0530 Subject: 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 72ba1a85cff..b26afb42e74 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 0e9dc7acfed..276816b2807 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 de9a1b0eb94..68642e2d8a7 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 e8bdbf1afb7..d7835dc6e2b 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 -- cgit v1.2.1 From 70add1388f514b353d92d2e6a1db0dc173290946 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 11 May 2016 10:25:21 +0530 Subject: Minor fixes after a final look at the diff. - Spaces around `{` and `}` in HAML. - Typo in CHANGELOG. - Remove i18n. --- CHANGELOG | 2 +- app/views/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 5b52977a9b4..d8d8a36e4b1 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 f7482d2c87d..0f51e8cd8be 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 b5b8c4467b0..cedb5e207bd 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." -- cgit v1.2.1 From 05b319b0b45288cbbe0bce15bf7bed7f58f6cf76 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 1 Jun 2016 14:04:38 +0530 Subject: 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 +++++----- spec/controllers/application_controller_spec.rb | 92 +++++++++++++------------ 2 files changed, 63 insertions(+), 67 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b26afb42e74..9dbaba00ff5 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 d7835dc6e2b..90dbd1183eb 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 - end - let(:user) { create(:user) } + 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' 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 "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") + 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 - 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 -- cgit v1.2.1 From 6d444331764fec1910d15b300d89b6246a1f83ea Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 1 Jun 2016 14:09:17 +0530 Subject: 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 9dbaba00ff5..17bd980b454 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 -- cgit v1.2.1 From c75aea5e4dc6f4420e7576a5c367a9e035f63646 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 1 Jun 2016 14:31:16 +0530 Subject: 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 ++---- app/views/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 af6def25e7f..1ad1c11b73f 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 0f51e8cd8be..b4468dd9839 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. -- cgit v1.2.1 From 4d50d8a6e3b8ae1521617df32af4cad18385fb9f Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 2 Jun 2016 08:27:47 +0530 Subject: Only show a personal access token right after its creation. --- app/assets/stylesheets/pages/profile.scss | 8 ++++++ .../profiles/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 53f16c13eca..69bc74228e8 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 1ad1c11b73f..81f2390a566 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 b4468dd9839..503f88efb43 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 e9fbeefae75..095c7d60e29 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 -- cgit v1.2.1 From a1295d8ebef223eb0a87bc9e1660efcb9147599c Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 08:58:15 +0530 Subject: 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 095c7d60e29..14f2a7c9a25 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 -- cgit v1.2.1 From b4b024857783e1fc39424fdf631b722ee1dfd195 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:00:39 +0530 Subject: Parts of spec names with "when" should be contexts. --- spec/controllers/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 90dbd1183eb..ff596e7c2ad 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 -- cgit v1.2.1 From 399a633061577b8a2ca95c29ce1cfe0abeac4779 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:07:37 +0530 Subject: 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 --- .../profiles/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 503f88efb43..68f2813bc1a 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 -- cgit v1.2.1 From ffe111c1e22b0cce827c297fea62dfb0bd91326a Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:23:16 +0530 Subject: Display appropriate errors when personal access token creation/revocation fails. --- .../profiles/personal_access_tokens_controller.rb | 12 +++++++++--- app/views/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 81f2390a566..a1545a5dd00 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 68f2813bc1a..64e632b20e5 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 -- cgit v1.2.1 From 1a416720a2261f453ec4ce539fa61c1c32769aea Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:31:56 +0530 Subject: Update CHANGELOG. --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 547921a0777..57a006869cf 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 -- cgit v1.2.1 From 3adf125a155fd04fbba4f0882c739eae1cc73e15 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 09:53:49 +0530 Subject: 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 14f2a7c9a25..5bba08d005c 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 -- cgit v1.2.1 From 0dff6fd7148957fa94d2626e3912cd929ba150d3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 3 Jun 2016 10:10:58 +0530 Subject: 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 c8baea2579b..23a0e16ca43 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 ff596e7c2ad..ff5b3916273 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 3e80a48175a..46eb71cef14 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 -- cgit v1.2.1 From af2f56f8f742e00ddb298fadea763fd0fe7054f0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 12:39:15 +0200 Subject: Refactor ci commit pipeline to prevent implicit saves --- app/models/ci/commit.rb | 16 ++++----------- app/services/ci/create_pipeline_service.rb | 24 ++++++++++------------ app/services/create_commit_builds_service.rb | 2 +- spec/services/create_commit_builds_service_spec.rb | 2 +- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index f22b573a94c..c682f3e570e 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 5bc0c31cb42..0336b767de5 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') + return pipeline + end - unless pipeline.config_processor - pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') - raise ActiveRecord::Rollback - end + pipeline.save! - pipeline.save! - pipeline.create_builds(current_user) - end - rescue - pipeline.errors.add(:base, 'The pipeline could not be created. Please try again.') + 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 5b6fefe669e..ee84023e514 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 9ae8f31b372..e643991e0b9 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 -- cgit v1.2.1 From 1bcb61dd2076995b5fed786133f94def1fd637a5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 13:38:30 +0200 Subject: Add specs covering case when there are no builds --- spec/services/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 e643991e0b9..8c6b602ac83 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 -- cgit v1.2.1 From 0d19abf450d26fa76a23aaae38d392ecdef4e1e0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 13:52:19 +0200 Subject: 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 c682f3e570e..ccd26959ad1 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 18274ce24e2..7fb2ad7e061 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 026a5ac97ca..fcc8af16488 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 8c6b602ac83..dc915e9dd77 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 -- cgit v1.2.1 From 53fe06efde46acd2df62f818c421ecf3a0b971c9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 14:41:48 +0200 Subject: Update ci commit pipeline specs according to changes --- spec/models/ci/commit_spec.rb | 16 ++++++++-------- spec/services/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 22f8639e5ab..0939eb946ac 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 dc915e9dd77..b116e3e8fb4 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 -- cgit v1.2.1 From 07af37a243ea0d6b5741754ea116044ee46614b3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 15:55:38 +0200 Subject: Do not create pipeline objects when no builds --- app/services/ci/create_builds_service.rb | 41 +++++++++++++++----------- app/services/create_commit_builds_service.rb | 10 +++---- spec/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 7fb2ad7e061..a02b0b8f9b3 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 ee84023e514..3f048157b3f 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 ecc3a88a262..8e737fd44f9 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 -- cgit v1.2.1 From c6bce7e63c305d07dbc91d032df9c783e0cf0c9f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 17:17:23 +0200 Subject: 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 64723ab6b4b..fd6fba42a34 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 ccd26959ad1..7e6bb4f8c1b 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 0336b767de5..864415ef747 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 993acf11db9..c611a963112 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 0939eb946ac..07b875e4f88 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 e5124ea5ea7..7eff8048667 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 } -- cgit v1.2.1 From 8e811f2c6c4c74a30789ff5213de5ebc28897753 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 21:02:52 +0200 Subject: 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 ++-- spec/services/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 a02b0b8f9b3..f458dee49a6 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 3f048157b3f..aa0ec45be8c 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 07b875e4f88..d36aca113a1 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 b116e3e8fb4..e3202c959d9 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 -- cgit v1.2.1 From 681b472c36d5079b620b93957d62dbacc473bf6f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 21:04:45 +0200 Subject: 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 d36aca113a1..a4549d40461 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 -- cgit v1.2.1 From a21d084ded6cdf5b83163d4d72bb5c636218d091 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 3 Jun 2016 10:01:00 +0200 Subject: 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 edc0bdec3db..16c572b3197 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) -- cgit v1.2.1 From d8c4556d3c8623bf48e689f3734c9c35cda34c2f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 3 Jun 2016 11:58:08 +0200 Subject: 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 +++++++++++++++---- app/services/ci/create_trigger_request_service.rb | 1 - app/services/create_commit_builds_service.rb | 2 +- .../features/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 fd6fba42a34..64723ab6b4b 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 7e6bb4f8c1b..cdab6d5f316 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 c611a963112..993acf11db9 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 aa0ec45be8c..dbfc93ff5bc 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 16c572b3197..edc0bdec3db 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 a4549d40461..01d931b087e 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 7eff8048667..e5124ea5ea7 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 } -- cgit v1.2.1 From 3b21174d32695d10124bd4d582db14947bf4162d Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Mon, 6 Jun 2016 15:50:58 +0200 Subject: 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 f6eedb1773c..fd57478fc9e 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 -- cgit v1.2.1 From 8aad78838374c761a69d7f0e9727706a611ebcaf Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:28 +0300 Subject: 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 2b86b289bbe..504abd8f3e4 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. -- cgit v1.2.1 From b13f6fa99dace85844db2591c922fe7158e7baac Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:47 +0300 Subject: 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 00000000000..3ec569f73ea --- /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 -- cgit v1.2.1 From 5b64e486cceda778161ee99da6f60a06c3ba4d08 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:11:23 +0300 Subject: 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 b49207fc315..b76e31f7dc8 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 -- cgit v1.2.1 From 495d27be382266513f159a5babc334a93b540a95 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:13:02 +0300 Subject: 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 5eb915a51ea..0ba2c4958a6 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 + @locationBadgeEl.hide() @resetSearchState() - @wrap.removeClass('has-location-badge') + @disableAutocomplete() + disableAutocomplete: -> @searchInput.addClass('disabled') -- cgit v1.2.1 From 36f67b305f37cdf4eb9f75f12cfde3b0dfc01183 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 14:49:25 +0300 Subject: 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 0ba2c4958a6..943dba9bcba 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}" } -- cgit v1.2.1 From 50b3b8ce80b3573f53c22ac5ff34391b5bc469d8 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 17:54:29 +0300 Subject: 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 +- .../javascripts/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 943dba9bcba..8493d2684d9 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 029a11ea43c..4f4d4b1e3e9 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 00000000000..7785120da5b --- /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 dd160e821b3..3a3c8d63e82 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 1cf34d4d2d3..9be29097f4c 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 00000000000..5212f5d223a --- /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 -- cgit v1.2.1 From 8827eea8643bba95571edf2ea0f769b18e8369c2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 08:28:18 +0100 Subject: Updated commits UI Closes #14633 --- app/assets/stylesheets/pages/commits.scss | 114 +++++++++++---------- 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, 104 insertions(+), 96 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c8c6bbde084..05949e2cd43 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -7,74 +7,84 @@ 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; + + &:first-child { + border-top-width: 0; + } } -.commits-row { - ul { - margin: 0; +.commit-row-title { + line-height: 20px; + margin-bottom: 2px; - li.commit { - padding: 8px 0; - } + .notes_count { + float: right; + margin-right: 10px; } - .commits-row-date { - font-size: 15px; - line-height: 20px; - margin-bottom: 5px; + .str-truncated { + max-width: 70%; } -} -li.commit { - list-style: none; + .commit-row-message { + color: $gl-dark-link-color; - .commit-row-title { - font-size: $list-font-size; - line-height: 20px; - margin-bottom: 2px; - - .btn-clipboard { - margin-top: -1px; + &: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; + } +} - .str-truncated { - max-width: 70%; - } +.commit-short-id { + font-family: $monospace_font; + font-weight: 600; +} - .commit-row-message { - color: $gl-dark-link-color; +.commit { + padding: 10px 0 10px 55px; - &:hover { - text-decoration: underline; - } - } + &:not(:last-child) { + border-bottom: 1px solid #eee; + } + + a { + color: $gl-dark-link-color; + } - .text-expander { - background: #eee; - color: #555; - padding: 0 5px; - cursor: pointer; - margin-left: 4px; - &:hover { - background-color: #ddd; - } + .commit-link { + &:hover { + color: $gl-link-color; + text-decoration: none; } } + .avatar { + margin-left: -55px; + } + .item-title { display: inline-block; max-width: 70%; @@ -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 07e5c146844..8e4ae1e6aec 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 d328f56c80c..767b346f2ff 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 367027182b6..288b95c3e6e 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 7283a78a64e..dd12eae8f7e 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 76ba0bea36d..51ca4eb903e 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 a8f09f855d4..0b05785430b 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 -- cgit v1.2.1 From 79b375e17876105cefcbc5c451e785aceedb0002 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 14:00:49 +0100 Subject: 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 05949e2cd43..d360a224848 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 f16fc7f388f..cfb6e2e888e 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 f742922d926..bf5505125ab 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 288b95c3e6e..f79c9448f60 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)} -- cgit v1.2.1 From 8b40a7745be84659c10db02e3bbb74126bd42414 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 17:06:49 +0100 Subject: 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 bf5505125ab..fabd726aae9 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 767b346f2ff..97d52b1fb9e 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 f79c9448f60..d6661deb5ff 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) -- cgit v1.2.1 From 48726e9d307536318c7d87e2ba93f93582e22bfa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 25 May 2016 09:11:47 +0100 Subject: 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 d360a224848..ba8d9cce49b 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 fabd726aae9..07a3f452460 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 97d52b1fb9e..3dbb6e4a551 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 2c0498de3b9..79a3ed8197e 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 -- cgit v1.2.1 From 97cee7e231689a7dee2f193411f3cd7962c6ea52 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:04:18 +0100 Subject: 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 ba8d9cce49b..2723fb0b6e0 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 3dbb6e4a551..55d65698292 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 d6661deb5ff..58ccd31442a 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') -- cgit v1.2.1 From d5afb1324f2fb9b9c19df3806662e159bbe4ffb3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:17:13 +0100 Subject: 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 58ccd31442a..66df5fe5e20 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)} -- cgit v1.2.1 From 6863444b7e7d71e6b50ed8ab09cda6f3e0117176 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:40:46 +0100 Subject: 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 2723fb0b6e0..93566be88d1 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; -- cgit v1.2.1 From e5a83a9a94f7fbedb2fcce645248e1198dcf474f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 10:17:32 +0100 Subject: 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 93566be88d1..a392993b38d 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 66df5fe5e20..757f4e7e8e0 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? -- cgit v1.2.1 From 41c2ea9b7a036da7064b433de43c19e578cc7531 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:09:46 +0100 Subject: 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 a392993b38d..335d9e5efd7 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 757f4e7e8e0..a959b34a539 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" -- cgit v1.2.1 From e7ee3f9f4d34f74bb40c0e439a2f7920e55ba3ba Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:16:18 +0100 Subject: 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 335d9e5efd7..b954ed50945 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; -- cgit v1.2.1 From b76ab726b2542d77d59b12457b62016d9205a5b2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Jun 2016 10:32:54 +0100 Subject: 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 b954ed50945..811f0765a27 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 { -- cgit v1.2.1 From 57ec290f0c47d04745e49598c490735b3e650edb Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 8 Jun 2016 16:37:13 +0300 Subject: Updated CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index bee1a824974..b37c23de40b 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 -- cgit v1.2.1 From c1818eec1d10688c05467a900f60259490b627c1 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:35 +0300 Subject: 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 b76e31f7dc8..5c6429d07b4 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 504abd8f3e4..33cedaaf2ee 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. -- cgit v1.2.1 From 7df512d5a209cc82b06020d6196a47d79b73f861 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:50 +0300 Subject: Add new utils. --- app/assets/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 3ec569f73ea..95c4dd319ab 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 -- cgit v1.2.1 From 522ef5754d222de1e8687c4b9bbc081478c69041 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:22:13 +0300 Subject: 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 8493d2684d9..421328554b8 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()] + + else if utils.isInProjectPage() and projectOptions + options = projectOptions[utils.getProjectSlug()] + + else if dashboardOptions + options = dashboardOptions - return [ - { header: "Go to in #{projectName}" } + { 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() -- cgit v1.2.1 From 1f5ecf916ee7b1d34fbf8775890b2aada2055384 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 9 Jun 2016 14:08:49 +0530 Subject: 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 --- app/assets/stylesheets/framework/variables.scss | 5 +++ app/assets/stylesheets/pages/profile.scss | 18 ++------ .../profiles/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 f253da814bc..fc3589dad65 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 a2bbe734d2a..88e062d156f 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 a1545a5dd00..86e08fed8e2 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 64e632b20e5..4ad07df42dc 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 5bba08d005c..105c5dae34e 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 -- cgit v1.2.1 From 1f192afa2abab5fcab693eaf3e0fa3c874cfb793 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 10:50:03 +0100 Subject: 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 811f0765a27..5a6e55cf63f 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; -- cgit v1.2.1 From e864bdf25b0082be8d0847fed6a2d16fe348ae59 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 10 Jun 2016 01:05:09 +0300 Subject: Fix specs and add new tests. --- spec/features/search_spec.rb | 4 +- .../javascripts/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 4f4d4b1e3e9..b9e63a7152c 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 5212f5d223a..e77177783a7 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 = $ 'body' - $('body').removeAttr 'data-page' - $('body').removeAttr 'data-project' + $body.removeAttr 'data-page' + $body.removeAttr 'data-project' + $body.removeAttr 'data-group' - $('body').data 'page', "#{page}:show" - $('body').data 'project', 'gitlab-ce' + 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 -- cgit v1.2.1 From e18a08fd89f59088db22208069370a85f6a33001 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 10 Jun 2016 08:49:05 +0530 Subject: 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 +++++++++++ app/views/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 88e062d156f..46371ec6871 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 4ad07df42dc..456e770dc80 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 105c5dae34e..d824e1d288d 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! -- cgit v1.2.1 From ffd07382b08586420628ae7ecda8a512adf091aa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 11:23:57 +0100 Subject: 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 de0eae58bff..de38d9fb26e 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 787c387379e..8b6370caa7d 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 fb906de829a..a1f6defafc4 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 -- cgit v1.2.1 From 492e0062172b5a6ea1e553f97b2ac410badc496f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:03:33 +0100 Subject: Corrected all sidebar font weights to correctly match the design --- app/assets/javascripts/due_date_select.js.coffee | 5 ++-- app/assets/javascripts/labels_select.js.coffee | 2 +- app/assets/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 3d009a96d05..d5cb3f620b1 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 ec74dfaae1a..6dff9fc4fd0 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 648e1f3bde0..a312103d82b 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 de38d9fb26e..cb7a700d028 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 8b6370caa7d..fb95aa22831 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 5e5d170a9f3..61c9a2254df 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 a1f6defafc4..1daad44fa1f 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 -- cgit v1.2.1 From 88f562469f20be70392cf30c34f421ea9b68bcb2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:05:32 +0100 Subject: 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 fb95aa22831..4145b26ed19 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 { -- cgit v1.2.1 From e15b17f855c03cb993d446985fccf36b13d4bd25 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:06:07 +0100 Subject: 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 4145b26ed19..ba658d9faca 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; } } -- cgit v1.2.1 From b0cf82105bbb3d61e559e8bb36abfa308cbee6ae Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:24:31 +0100 Subject: 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 f6fb6a72d22..32d1e631408 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 -- cgit v1.2.1 From a99d25b174e07bf58ae8d0c5055291065038f81a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 08:42:03 +0100 Subject: 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 d5cb3f620b1..32c143cae16 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 6dff9fc4fd0..5df3af6091a 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) -- cgit v1.2.1 From 9d09bd08e541c42dc05e336293cc2917b3a60df8 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 08:30:38 +0100 Subject: 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 b00c149a753..07c7ad19c5f 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 29ac0f70b30..08ab9604361 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 3d009a96d05..99d59eca9cb 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 f5e2b927da8..cbf1ba04170 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())); -- cgit v1.2.1 From ab722343ada1f79cc7274721ec707dc7760f3fce Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:23:17 +0100 Subject: 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 99d59eca9cb..401433b0732 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() -- cgit v1.2.1 From ff702bd6e05520a2cb695a03984a35ed981c6561 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 12:04:21 +0100 Subject: 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 401433b0732..2a79a0c3d86 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') -- cgit v1.2.1 From 18e16e427d331415db042afd3c8dd5689db32a53 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Tue, 7 Jun 2016 15:07:00 -0600 Subject: 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 - app/assets/javascripts/branch-graph.js.coffee | 340 - app/assets/javascripts/network.js.coffee | 9 - .../javascripts/network/application.js.coffee | 20 + .../javascripts/network/branch-graph.js.coffee | 340 + app/assets/javascripts/network/network.js.coffee | 9 + app/views/projects/network/show.html.haml | 12 +- config/application.rb | 1 + vendor/assets/javascripts/raphael.js | 8239 ++++++++++++++++++++ 11 files changed, 8611 insertions(+), 366 deletions(-) delete mode 100644 app/assets/javascripts/branch-graph.js.coffee delete mode 100644 app/assets/javascripts/network.js.coffee create mode 100644 app/assets/javascripts/network/application.js.coffee create mode 100644 app/assets/javascripts/network/branch-graph.js.coffee create mode 100644 app/assets/javascripts/network/network.js.coffee create mode 100644 vendor/assets/javascripts/raphael.js diff --git a/Gemfile b/Gemfile index b2660144f2b..5a058e10046 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 dfc15700494..00276d238c0 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 b28327ce12d..228f48ad7c5 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/branch-graph.js.coffee b/app/assets/javascripts/branch-graph.js.coffee deleted file mode 100644 index f2fd2a775a4..00000000000 --- a/app/assets/javascripts/branch-graph.js.coffee +++ /dev/null @@ -1,340 +0,0 @@ -class @BranchGraph - constructor: (@element, @options) -> - @preparedCommits = {} - @mtime = 0 - @mspace = 0 - @parents = {} - @colors = ["#000"] - @offsetX = 150 - @offsetY = 20 - @unitTime = 30 - @unitSpace = 10 - @prev_start = -1 - @load() - - load: -> - $.ajax - url: @options.url - method: "get" - dataType: "json" - success: $.proxy((data) -> - $(".loading", @element).hide() - @prepareData data.days, data.commits - @buildGraph() - , this) - - prepareData: (@days, @commits) -> - @collectParents() - @graphHeight = $(@element).height() - @graphWidth = $(@element).width() - ch = Math.max(@graphHeight, @offsetY + @unitTime * @mtime + 150) - cw = Math.max(@graphWidth, @offsetX + @unitSpace * @mspace + 300) - @r = Raphael(@element.get(0), cw, ch) - @top = @r.set() - @barHeight = Math.max(@graphHeight, @unitTime * @days.length + 320) - - for c in @commits - c.isParent = true if c.id of @parents - @preparedCommits[c.id] = c - @markCommit(c) - - @collectColors() - - collectParents: -> - for c in @commits - @mtime = Math.max(@mtime, c.time) - @mspace = Math.max(@mspace, c.space) - for p in c.parents - @parents[p[0]] = true - @mspace = Math.max(@mspace, p[1]) - - collectColors: -> - k = 0 - while k < @mspace - @colors.push Raphael.getColor(.8) - # Skipping a few colors in the spectrum to get more contrast between colors - Raphael.getColor() - Raphael.getColor() - k++ - - buildGraph: -> - r = @r - cuday = 0 - cumonth = "" - - r.rect(0, 0, 40, @barHeight).attr fill: "#222" - r.rect(40, 0, 30, @barHeight).attr fill: "#444" - - for day, mm in @days - if cuday isnt day[0] || cumonth isnt day[1] - # Dates - r.text(55, @offsetY + @unitTime * mm, day[0]) - .attr( - font: "12px Monaco, monospace" - fill: "#BBB" - ) - cuday = day[0] - - if cumonth isnt day[1] - # Months - r.text(20, @offsetY + @unitTime * mm, day[1]) - .attr( - font: "12px Monaco, monospace" - fill: "#EEE" - ) - cumonth = day[1] - - @renderPartialGraph() - - @bindEvents() - - renderPartialGraph: -> - start = Math.floor((@element.scrollTop() - @offsetY) / @unitTime) - 10 - if start < 0 - isGraphEdge = true - start = 0 - end = start + 40 - if @commits.length < end - isGraphEdge = true - end = @commits.length - - if @prev_start == -1 or Math.abs(@prev_start - start) > 10 or isGraphEdge - i = start - - @prev_start = start - - while i < end - commit = @commits[i] - i += 1 - - if commit.hasDrawn isnt true - x = @offsetX + @unitSpace * (@mspace - commit.space) - y = @offsetY + @unitTime * commit.time - - @drawDot(x, y, commit) - - @drawLines(x, y, commit) - - @appendLabel(x, y, commit) - - @appendAnchor(x, y, commit) - - commit.hasDrawn = true - - @top.toFront() - - bindEvents: -> - element = @element - - $(element).scroll (event) => - @renderPartialGraph() - - scrollDown: => - @element.scrollTop @element.scrollTop() + 50 - @renderPartialGraph() - - scrollUp: => - @element.scrollTop @element.scrollTop() - 50 - @renderPartialGraph() - - scrollLeft: => - @element.scrollLeft @element.scrollLeft() - 50 - @renderPartialGraph() - - scrollRight: => - @element.scrollLeft @element.scrollLeft() + 50 - @renderPartialGraph() - - scrollBottom: => - @element.scrollTop @element.find('svg').height() - - scrollTop: => - @element.scrollTop 0 - - appendLabel: (x, y, commit) -> - return unless commit.refs - - r = @r - shortrefs = commit.refs - # Truncate if longer than 15 chars - shortrefs = shortrefs.substr(0, 15) + "…" if shortrefs.length > 17 - text = r.text(x + 4, y, shortrefs).attr( - "text-anchor": "start" - font: "10px Monaco, monospace" - fill: "#FFF" - title: commit.refs - ) - textbox = text.getBBox() - # Create rectangle based on the size of the textbox - rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - triangle = r.path(["M", x - 5, y, "L", x - 15, y - 4, "L", x - 15, y + 4, "Z"]).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - - label = r.set(rect, text) - label.transform(["t", -rect.getBBox().width - 15, 0]) - - # Set text to front - text.toFront() - - appendAnchor: (x, y, commit) -> - r = @r - top = @top - options = @options - anchor = r.circle(x, y, 10).attr( - fill: "#000" - opacity: 0 - cursor: "pointer" - ).click(-> - window.open options.commit_url.replace("%s", commit.id), "_blank" - ).hover(-> - @tooltip = r.commitTooltip(x + 5, y, commit) - top.push @tooltip.insertBefore(this) - , -> - @tooltip and @tooltip.remove() and delete @tooltip - ) - top.push anchor - - drawDot: (x, y, commit) -> - r = @r - r.circle(x, y, 3).attr( - fill: @colors[commit.space] - stroke: "none" - ) - - avatar_box_x = @offsetX + @unitSpace * @mspace + 10 - avatar_box_y = y - 10 - r.rect(avatar_box_x, avatar_box_y, 20, 20).attr( - stroke: @colors[commit.space] - "stroke-width": 2 - ) - r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20) - r.text(@offsetX + @unitSpace * @mspace + 35, y, commit.message.split("\n")[0]).attr( - "text-anchor": "start" - font: "14px Monaco, monospace" - ) - - drawLines: (x, y, commit) -> - r = @r - for parent, i in commit.parents - parentCommit = @preparedCommits[parent[0]] - parentY = @offsetY + @unitTime * parentCommit.time - parentX1 = @offsetX + @unitSpace * (@mspace - parentCommit.space) - parentX2 = @offsetX + @unitSpace * (@mspace - parent[1]) - - # Set line color - if parentCommit.space <= commit.space - color = @colors[commit.space] - - else - color = @colors[parentCommit.space] - - # Build line shape - if parent[1] is commit.space - offset = [0, 5] - arrow = "l-2,5,4,0,-2,-5,0,5" - - else if parent[1] < commit.space - offset = [3, 3] - arrow = "l5,0,-2,4,-3,-4,4,2" - - else - offset = [-3, 3] - arrow = "l-5,0,2,4,3,-4,-4,2" - - # Start point - route = ["M", x + offset[0], y + offset[1]] - - # Add arrow if not first parent - if i > 0 - route.push(arrow) - - # Circumvent if overlap - if commit.space isnt parentCommit.space or commit.space isnt parent[1] - route.push( - "L", parentX2, y + 10, - "L", parentX2, parentY - 5, - ) - - # End point - route.push("L", parentX1, parentY) - - r - .path(route) - .attr( - stroke: color - "stroke-width": 2) - - markCommit: (commit) -> - if commit.id is @options.commit_id - r = @r - x = @offsetX + @unitSpace * (@mspace - commit.space) - y = @offsetY + @unitTime * commit.time - r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - # Displayed in the center - @element.scrollTop(y - @graphHeight / 2) - -Raphael::commitTooltip = (x, y, commit) -> - boxWidth = 300 - boxHeight = 200 - icon = @image(gon.relative_url_root + commit.author.icon, x, y, 20, 20) - nameText = @text(x + 25, y + 10, commit.author.name) - idText = @text(x, y + 35, commit.id) - messageText = @text(x, y + 50, commit.message) - textSet = @set(icon, nameText, idText, messageText).attr( - "text-anchor": "start" - font: "12px Monaco, monospace" - ) - nameText.attr( - font: "14px Arial" - "font-weight": "bold" - ) - - idText.attr fill: "#AAA" - @textWrap messageText, boxWidth - 50 - rect = @rect(x - 10, y - 10, boxWidth, 100, 4).attr( - fill: "#FFF" - stroke: "#000" - "stroke-linecap": "round" - "stroke-width": 2 - ) - tooltip = @set(rect, textSet) - rect.attr( - height: tooltip.getBBox().height + 10 - width: tooltip.getBBox().width + 10 - ) - - tooltip.transform ["t", 20, 20] - tooltip - -Raphael::textWrap = (t, width) -> - content = t.attr("text") - abc = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - t.attr text: abc - letterWidth = t.getBBox().width / abc.length - t.attr text: content - words = content.split(" ") - x = 0 - s = [] - - for word in words - if x + (word.length * letterWidth) > width - s.push "\n" - x = 0 - x += word.length * letterWidth - s.push word + " " - - t.attr text: s.join("") - b = t.getBBox() - h = Math.abs(b.y2) - Math.abs(b.y) + 1 - t.attr y: b.y + h diff --git a/app/assets/javascripts/network.js.coffee b/app/assets/javascripts/network.js.coffee deleted file mode 100644 index f4ef07a50a7..00000000000 --- a/app/assets/javascripts/network.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class @Network - constructor: (opts) -> - $("#filter_ref").click -> - $(this).closest('form').submit() - - @branch_graph = new BranchGraph($(".network-graph"), opts) - - vph = $(window).height() - 250 - $('.network-graph').css 'height': (vph + 'px') diff --git a/app/assets/javascripts/network/application.js.coffee b/app/assets/javascripts/network/application.js.coffee new file mode 100644 index 00000000000..cb9eead855b --- /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/network/branch-graph.js.coffee b/app/assets/javascripts/network/branch-graph.js.coffee new file mode 100644 index 00000000000..f2fd2a775a4 --- /dev/null +++ b/app/assets/javascripts/network/branch-graph.js.coffee @@ -0,0 +1,340 @@ +class @BranchGraph + constructor: (@element, @options) -> + @preparedCommits = {} + @mtime = 0 + @mspace = 0 + @parents = {} + @colors = ["#000"] + @offsetX = 150 + @offsetY = 20 + @unitTime = 30 + @unitSpace = 10 + @prev_start = -1 + @load() + + load: -> + $.ajax + url: @options.url + method: "get" + dataType: "json" + success: $.proxy((data) -> + $(".loading", @element).hide() + @prepareData data.days, data.commits + @buildGraph() + , this) + + prepareData: (@days, @commits) -> + @collectParents() + @graphHeight = $(@element).height() + @graphWidth = $(@element).width() + ch = Math.max(@graphHeight, @offsetY + @unitTime * @mtime + 150) + cw = Math.max(@graphWidth, @offsetX + @unitSpace * @mspace + 300) + @r = Raphael(@element.get(0), cw, ch) + @top = @r.set() + @barHeight = Math.max(@graphHeight, @unitTime * @days.length + 320) + + for c in @commits + c.isParent = true if c.id of @parents + @preparedCommits[c.id] = c + @markCommit(c) + + @collectColors() + + collectParents: -> + for c in @commits + @mtime = Math.max(@mtime, c.time) + @mspace = Math.max(@mspace, c.space) + for p in c.parents + @parents[p[0]] = true + @mspace = Math.max(@mspace, p[1]) + + collectColors: -> + k = 0 + while k < @mspace + @colors.push Raphael.getColor(.8) + # Skipping a few colors in the spectrum to get more contrast between colors + Raphael.getColor() + Raphael.getColor() + k++ + + buildGraph: -> + r = @r + cuday = 0 + cumonth = "" + + r.rect(0, 0, 40, @barHeight).attr fill: "#222" + r.rect(40, 0, 30, @barHeight).attr fill: "#444" + + for day, mm in @days + if cuday isnt day[0] || cumonth isnt day[1] + # Dates + r.text(55, @offsetY + @unitTime * mm, day[0]) + .attr( + font: "12px Monaco, monospace" + fill: "#BBB" + ) + cuday = day[0] + + if cumonth isnt day[1] + # Months + r.text(20, @offsetY + @unitTime * mm, day[1]) + .attr( + font: "12px Monaco, monospace" + fill: "#EEE" + ) + cumonth = day[1] + + @renderPartialGraph() + + @bindEvents() + + renderPartialGraph: -> + start = Math.floor((@element.scrollTop() - @offsetY) / @unitTime) - 10 + if start < 0 + isGraphEdge = true + start = 0 + end = start + 40 + if @commits.length < end + isGraphEdge = true + end = @commits.length + + if @prev_start == -1 or Math.abs(@prev_start - start) > 10 or isGraphEdge + i = start + + @prev_start = start + + while i < end + commit = @commits[i] + i += 1 + + if commit.hasDrawn isnt true + x = @offsetX + @unitSpace * (@mspace - commit.space) + y = @offsetY + @unitTime * commit.time + + @drawDot(x, y, commit) + + @drawLines(x, y, commit) + + @appendLabel(x, y, commit) + + @appendAnchor(x, y, commit) + + commit.hasDrawn = true + + @top.toFront() + + bindEvents: -> + element = @element + + $(element).scroll (event) => + @renderPartialGraph() + + scrollDown: => + @element.scrollTop @element.scrollTop() + 50 + @renderPartialGraph() + + scrollUp: => + @element.scrollTop @element.scrollTop() - 50 + @renderPartialGraph() + + scrollLeft: => + @element.scrollLeft @element.scrollLeft() - 50 + @renderPartialGraph() + + scrollRight: => + @element.scrollLeft @element.scrollLeft() + 50 + @renderPartialGraph() + + scrollBottom: => + @element.scrollTop @element.find('svg').height() + + scrollTop: => + @element.scrollTop 0 + + appendLabel: (x, y, commit) -> + return unless commit.refs + + r = @r + shortrefs = commit.refs + # Truncate if longer than 15 chars + shortrefs = shortrefs.substr(0, 15) + "…" if shortrefs.length > 17 + text = r.text(x + 4, y, shortrefs).attr( + "text-anchor": "start" + font: "10px Monaco, monospace" + fill: "#FFF" + title: commit.refs + ) + textbox = text.getBBox() + # Create rectangle based on the size of the textbox + rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr( + fill: "#000" + "fill-opacity": .5 + stroke: "none" + ) + triangle = r.path(["M", x - 5, y, "L", x - 15, y - 4, "L", x - 15, y + 4, "Z"]).attr( + fill: "#000" + "fill-opacity": .5 + stroke: "none" + ) + + label = r.set(rect, text) + label.transform(["t", -rect.getBBox().width - 15, 0]) + + # Set text to front + text.toFront() + + appendAnchor: (x, y, commit) -> + r = @r + top = @top + options = @options + anchor = r.circle(x, y, 10).attr( + fill: "#000" + opacity: 0 + cursor: "pointer" + ).click(-> + window.open options.commit_url.replace("%s", commit.id), "_blank" + ).hover(-> + @tooltip = r.commitTooltip(x + 5, y, commit) + top.push @tooltip.insertBefore(this) + , -> + @tooltip and @tooltip.remove() and delete @tooltip + ) + top.push anchor + + drawDot: (x, y, commit) -> + r = @r + r.circle(x, y, 3).attr( + fill: @colors[commit.space] + stroke: "none" + ) + + avatar_box_x = @offsetX + @unitSpace * @mspace + 10 + avatar_box_y = y - 10 + r.rect(avatar_box_x, avatar_box_y, 20, 20).attr( + stroke: @colors[commit.space] + "stroke-width": 2 + ) + r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20) + r.text(@offsetX + @unitSpace * @mspace + 35, y, commit.message.split("\n")[0]).attr( + "text-anchor": "start" + font: "14px Monaco, monospace" + ) + + drawLines: (x, y, commit) -> + r = @r + for parent, i in commit.parents + parentCommit = @preparedCommits[parent[0]] + parentY = @offsetY + @unitTime * parentCommit.time + parentX1 = @offsetX + @unitSpace * (@mspace - parentCommit.space) + parentX2 = @offsetX + @unitSpace * (@mspace - parent[1]) + + # Set line color + if parentCommit.space <= commit.space + color = @colors[commit.space] + + else + color = @colors[parentCommit.space] + + # Build line shape + if parent[1] is commit.space + offset = [0, 5] + arrow = "l-2,5,4,0,-2,-5,0,5" + + else if parent[1] < commit.space + offset = [3, 3] + arrow = "l5,0,-2,4,-3,-4,4,2" + + else + offset = [-3, 3] + arrow = "l-5,0,2,4,3,-4,-4,2" + + # Start point + route = ["M", x + offset[0], y + offset[1]] + + # Add arrow if not first parent + if i > 0 + route.push(arrow) + + # Circumvent if overlap + if commit.space isnt parentCommit.space or commit.space isnt parent[1] + route.push( + "L", parentX2, y + 10, + "L", parentX2, parentY - 5, + ) + + # End point + route.push("L", parentX1, parentY) + + r + .path(route) + .attr( + stroke: color + "stroke-width": 2) + + markCommit: (commit) -> + if commit.id is @options.commit_id + r = @r + x = @offsetX + @unitSpace * (@mspace - commit.space) + y = @offsetY + @unitTime * commit.time + r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr( + fill: "#000" + "fill-opacity": .5 + stroke: "none" + ) + # Displayed in the center + @element.scrollTop(y - @graphHeight / 2) + +Raphael::commitTooltip = (x, y, commit) -> + boxWidth = 300 + boxHeight = 200 + icon = @image(gon.relative_url_root + commit.author.icon, x, y, 20, 20) + nameText = @text(x + 25, y + 10, commit.author.name) + idText = @text(x, y + 35, commit.id) + messageText = @text(x, y + 50, commit.message) + textSet = @set(icon, nameText, idText, messageText).attr( + "text-anchor": "start" + font: "12px Monaco, monospace" + ) + nameText.attr( + font: "14px Arial" + "font-weight": "bold" + ) + + idText.attr fill: "#AAA" + @textWrap messageText, boxWidth - 50 + rect = @rect(x - 10, y - 10, boxWidth, 100, 4).attr( + fill: "#FFF" + stroke: "#000" + "stroke-linecap": "round" + "stroke-width": 2 + ) + tooltip = @set(rect, textSet) + rect.attr( + height: tooltip.getBBox().height + 10 + width: tooltip.getBBox().width + 10 + ) + + tooltip.transform ["t", 20, 20] + tooltip + +Raphael::textWrap = (t, width) -> + content = t.attr("text") + abc = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + t.attr text: abc + letterWidth = t.getBBox().width / abc.length + t.attr text: content + words = content.split(" ") + x = 0 + s = [] + + for word in words + if x + (word.length * letterWidth) > width + s.push "\n" + x = 0 + x += word.length * letterWidth + s.push word + " " + + t.attr text: s.join("") + b = t.getBBox() + h = Math.abs(b.y2) - Math.abs(b.y) + 1 + t.attr y: b.y + h diff --git a/app/assets/javascripts/network/network.js.coffee b/app/assets/javascripts/network/network.js.coffee new file mode 100644 index 00000000000..f4ef07a50a7 --- /dev/null +++ b/app/assets/javascripts/network/network.js.coffee @@ -0,0 +1,9 @@ +class @Network + constructor: (opts) -> + $("#filter_ref").click -> + $(this).closest('form').submit() + + @branch_graph = new BranchGraph($(".network-graph"), opts) + + vph = $(window).height() - 250 + $('.network-graph').css 'height': (vph + 'px') diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index bf9baaea889..3c155e97f72 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 49d4d3ba555..05fec995ed3 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 00000000000..3f3f8a0b7f6 --- /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; +})); -- cgit v1.2.1 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: 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 3c155e97f72..e4ab064eda8 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 -- cgit v1.2.1 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: .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 f16fc7f388f..770bbdfc265 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; } -- cgit v1.2.1 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: 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 49a4727205a..894f80586f1 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 -- cgit v1.2.1 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: 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 0c712b445a4..58bd01741db 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 cbe47176831..e22dce59d0f 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 d9c4b410d32..1e8d99f06eb 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 -- cgit v1.2.1 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: 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 6fe57c737b3..dde189a21d5 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 { -- cgit v1.2.1 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: 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 a036799e15a..4c3fea4df84 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; + } + } } } -- cgit v1.2.1 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: 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 0c712b445a4..5e8e192538f 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 29ac0f70b30..b5892dacf2c 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 f3d66004138..c03877e9b06 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 00000000000..6d21e5d1150 --- /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 -- cgit v1.2.1 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: 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 0c712b445a4..5969e18701c 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 29ac0f70b30..4c50e540980 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 -- cgit v1.2.1 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: 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 a0ffa065067..b8d8758fd2b 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? -- cgit v1.2.1 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: 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 136db8ee14d..030f1564862 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' -- cgit v1.2.1 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: 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 a26f8aeb315..4e2702c2e44 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") -- cgit v1.2.1 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: Fixed issue with MR buttons being in a group Also removed some inline code --- app/assets/javascripts/dispatcher.js.coffee | 4 ++ app/assets/javascripts/merged_buttons.js.coffee | 30 +++++++++++ app/assets/stylesheets/pages/merge_requests.scss | 10 ++++ app/helpers/commits_helper.rb | 6 +-- .../merge_requests/widget/_merged.html.haml | 59 ++++++++-------------- .../merge_requests/widget/_merged_buttons.haml | 4 +- 6 files changed, 70 insertions(+), 43 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 29ac0f70b30..8b39e6b090c 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 00000000000..4929295c10b --- /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 a47f2580aa3..53bff508c72 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 d328f56c80c..493505e0c95 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 ec4beae9727..19b5d0ff066 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(); - }); + 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").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 + .remove_source_branch_in_progress.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' + = 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 56167509af9..d836a253507 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 -- cgit v1.2.1 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: 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 5c6429d07b4..4587cf50653 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}"] = { -- cgit v1.2.1 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: 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 0c084118753..35d728aec83 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; } -- cgit v1.2.1 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: Fair usage of Shared Runners --- CHANGELOG | 1 + app/services/ci/register_build_service.rb | 25 ++++++++++++---- spec/services/ci/register_build_service_spec.rb | 40 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919da..3c1a55d7771 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 4ff268a6f06..54aceba1c87 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 d91fc574299..fa4c2fddeb8 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) } -- cgit v1.2.1 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: 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 4587cf50653..245b9c3b4d4 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))}" }; -- cgit v1.2.1 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: 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 f2e97335092..1b6bfd22294 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 da58be5185b..190bb38504c 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: -- cgit v1.2.1 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: 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 54aceba1c87..9583f6c7c49 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 -- cgit v1.2.1 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: 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 c29d8ee6737..9c193f901e2 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) -- cgit v1.2.1 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: 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 c8c6bbde084..f8edf7d601b 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 { -- cgit v1.2.1 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: 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 2c9f773e308..ce0b33f32bd 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 -- cgit v1.2.1 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: Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 3387394de5b..093806d2c17 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 -- cgit v1.2.1 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: 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 fab96404a6c..d5fe5bc2ef1 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 7c2b8d01508..e0ca2a3109c 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 -- cgit v1.2.1 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: 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 01d931b087e..00000000000 --- 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 -- cgit v1.2.1 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: Improve code clarity in pipeline create service --- app/models/ci/pipeline.rb | 2 +- app/services/create_commit_builds_service.rb | 52 ++++++++++++++++------ spec/services/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 83d683b63e4..63639ff2c1f 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 f2a537c595e..668d0a86549 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 + + ## + # Skip creating builds when CI config is invalid + # but save pipeline object + # + unless @pipeline.config_processor + return save_pipeline! + end + + ## + # 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 - # Create a new pipeline - pipeline.save! + save_pipeline! + end + + private - pipeline.touch - pipeline + ## + # 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 08cbc9beb5c..50ce9659c10 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 -- cgit v1.2.1 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: 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 e71a154d1d5..2bc9def1ad5 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 c4df8bd504f..b60bbb7532e 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 -- cgit v1.2.1 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: 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 639b28d49ee..1bd354815e4 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 -- cgit v1.2.1 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: 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 63639ff2c1f..e90924af312 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 0d769ed7324..458013ad9f2 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 -- cgit v1.2.1 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: 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 e90924af312..fde03f21f9b 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 -- cgit v1.2.1 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: 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 fde03f21f9b..58c69251824 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 -- cgit v1.2.1 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: 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 1f46e331427..dabfd91cf14 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 22ebc7039d1..3cb67d51c85 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() -- cgit v1.2.1 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: Add Changelog entry for pipeline status fix --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 2aed8eb322b..4defd85ef10 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. -- cgit v1.2.1 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: Add support for Docker Registry manifest v1 --- CHANGELOG | 1 + .../projects/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 | 89 ++++++++++++++++------ 7 files changed, 124 insertions(+), 32 deletions(-) create mode 100644 spec/fixtures/container_registry/tag_manifest_1.json diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a95..8abefd618d0 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 4e9f936539b..d5fa07fd180 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 4e20dc4f875..eb5a2596177 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 4d726692f45..e0b3f14d384 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 43f8d6dc8c2..7a0929d774e 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 00000000000..d09ede5bea7 --- /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 858cb0bb134..c7324c2bf77 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 'schema v1' 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_1.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v1+prettyjws' }) + end - context '#layers' do - subject { tag.layers } + context '#layers' do + subject { tag.layers } - it { expect(subject.length).to eq(1) } - end + it { expect(subject.length).to eq(1) } + end + + context '#total_size' do + subject { tag.total_size } - context '#total_size' do - subject { tag.total_size } + it { is_expected.to be_nil } + end - it { is_expected.to eq(2319870) } + 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 'config processing' do + context 'schema v2' 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.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) 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.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 } + it { is_expected.not_to be_nil } + end end end end -- cgit v1.2.1 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: 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 9583f6c7c49..f0ed09a629a 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 -- cgit v1.2.1 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: Fixed issue with de-selecting dropdown option in issue sidebar Closes #18641 --- app/assets/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 648e1f3bde0..b108f747bd6 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 88246b0feb8..3dbc1d7f14f 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 f6fb6a72d22..65fe918e2e8 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 -- cgit v1.2.1 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: 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 58c69251824..a26cb7dd7ee 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 f7f73aff989..b2882b23d31 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 668d0a86549..f947e8f452e 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 458013ad9f2..34507cf5083 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 50ce9659c10..deab242f45a 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) -- cgit v1.2.1 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: 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 02dbb2985a4..71cf5582a4c 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 -- cgit v1.2.1 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: 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 0000e99a650..5e3a802f45f 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 f27780dda93..01b28171f72 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 -- cgit v1.2.1 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: Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a95..be9b5315c5a 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 -- cgit v1.2.1 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: 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 8f5c3393dfc..d7a0c25a044 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 adbf5941a96..7e3f5abba62 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 9f5adee594a..00000000000 --- 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 00000000000..624fd00367e --- /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 388f84dbe0e..40e8299c36b 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 -- cgit v1.2.1 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: 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 6591c52bdbd..87064cc9b3f 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 f292730fe45..b2539a1beac 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 -- cgit v1.2.1 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: 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 b2539a1beac..6258e6fd54b 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 -- cgit v1.2.1 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: 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 6258e6fd54b..1d53f715e86 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 -- cgit v1.2.1 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: 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 00000000000..b1adc316b50 --- /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 6dd2fef395d..4682016a886 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 + 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 - = link_to [:admin, group], class: 'str-truncated' do - = group.name + LDAP %span.light.pull-right - #{time_ago_with_tooltip(group.created_at)} + = 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 775072a7441..4f1996ef7ab 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 aa07afa0d62..4822cb693c2 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 d6743081c8e..d0a696da64b 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 1d53f715e86..9d85ec1d6d1 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 -- cgit v1.2.1 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: Add monitoring link with subtabs --- app/views/admin/background_jobs/_head.html.haml | 14 ++++ app/views/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 00000000000..ce7693bcad8 --- /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 de5bc050cf0..654d261aa99 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 c2313986a7f..7b8407f9152 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 698feb571ac..5ddc3b9ea85 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 9d85ec1d6d1..ad25d4908ff 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 -- cgit v1.2.1 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: Move builds tab to admin overview --- app/views/admin/builds/index.html.haml | 103 ++++++++++++++++-------------- app/views/admin/dashboard/_head.html.haml | 4 ++ app/views/layouts/nav/_admin.html.haml | 6 +- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index d74cf8598e8..efd5b12cfeb 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) - - %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)) - - %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)) - - .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 - -.row-content-block.second-block - #{(@scope || 'all').capitalize} builds - -%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' +- @no_container = true += render "admin/dashboard/head" + +%div{ class: (container_class) } + + .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) + + %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)) + + %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)) + + .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 + + .row-content-block.second-block + #{(@scope || 'all').capitalize} builds + + %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 b1adc316b50..ef9d246b2a2 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 ad25d4908ff..a72f1017132 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 -- cgit v1.2.1 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: 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 ce7693bcad8..d78682532ed 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 ef9d246b2a2..617db25a7a6 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 -- cgit v1.2.1 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: 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 d5fa07fd180..f35faa6afb5 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 -- cgit v1.2.1 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: 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 | 36 ++++++++++++++++-------------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/features/admin/active_tab.feature b/features/admin/active_tab.feature index 5de07e90e28..f5bb06dea7d 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 f2db1801389..9b1689a8198 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 main tab should be Messages' do - ensure_active_main_tab('Messages') + step 'the active sub tab should be Resque' do + ensure_active_sub_tab('Background Jobs') end - step 'no other main tabs should be active' do - expect(page).to have_selector('.nav-sidebar > li.active', count: 1) + step 'the active sub tab should be Logs' do + ensure_active_sub_tab('Logs') end - def ensure_active_main_tab(content) - expect(find('.nav-sidebar > li.active')).to have_content(content) + step 'the active main tab should be Messages' do + ensure_active_main_tab('Messages') end end -- cgit v1.2.1 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: Make sure that we test RegisterBuildService behavior for deleted projects --- spec/services/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 fa4c2fddeb8..f28f2f1438d 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) -- cgit v1.2.1 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: 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 617db25a7a6..7b3f88c24df 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 a72f1017132..54aa34bee0b 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 -- cgit v1.2.1 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: 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> --- .../20160615142710_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 00000000000..63f7392e54f --- /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 e148a3c975d..6a3be7297e3 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 -- cgit v1.2.1 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: 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 b0b3a51ce58..da71de4cd1e 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 2fb3a41d541..45f8ef89060 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))}'); -- cgit v1.2.1 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: 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 365a062bb81..b06bcf0fcbf 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 b34ec16cdba..a12c0bba44a 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 829222509f0..7b856db236f 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 bc65404a741..046c38aba44 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 6e1baa46b05..aa4d69550ec 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 478c04318c6..77676454b57 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' } -- cgit v1.2.1 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: Prevent default disabled buttons and links. --- CHANGELOG | 1 + app/assets/javascripts/application.js.coffee | 1 + app/assets/javascripts/lib/common_utils.js.coffee | 7 ++++++ spec/javascripts/application_spec.js.coffee | 30 +++++++++++++++++++++++ spec/javascripts/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 a215d794670..bb5bde9b08b 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 69d4c4f5dd3..6c16f89cef6 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 5e3a802f45f..4f1779b8483 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 00000000000..8af39c41f2f --- /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 00000000000..3fc6114407d --- /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 -- cgit v1.2.1 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: 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 7265cdac7a7..31633817d53 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 -- cgit v1.2.1 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: 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 c4df8bd504f..01887f1002d 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" -- cgit v1.2.1 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: 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 a53828ef4e7..877c77050be 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 c69d4cbfbe3..0191814849a 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 0b1a76156e0..7998209b7b0 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) } -- cgit v1.2.1 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: Pinned sidebar navigation option Closes #18542 --- app/assets/javascripts/application.js.coffee | 26 ++++- app/assets/javascripts/sidebar.js.coffee | 8 +- app/assets/stylesheets/framework/gitlab-theme.scss | 7 +- app/assets/stylesheets/framework/header.scss | 5 - app/assets/stylesheets/framework/sidebar.scss | 119 ++++++++++++--------- 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, 123 insertions(+), 75 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd3..030ef3a60b7 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 2ce63c16428..e7471893d2e 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 408d4a68e1e..bb09de4121f 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 63996ea44f6..595b541379a 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 4668e7e911b..64b2725abfa 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 22px; - position: fixed; - bottom: 0; - width: $sidebar_width; - overflow: hidden; - transition-duration: .3s; +.sidebar-user { + padding: 15px; + position: absolute; + left: 0; + bottom: 0; + width: $sidebar_width; + overflow: hidden; + 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; +} + +.nav-header-btn { + padding: 10px 5px; + color: inherit; transition-duration: .3s; - outline: none; - &:hover { + &:hover, + &:focus { + color: $white-light; text-decoration: none; } } -.sidebar-wrapper { - &.hidden-nav { - width: 0; +.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 469accf3142..d53ee3c45df 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 e4fab897377..5442ee4efe3 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 f89e8582792..90e872c461d 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 ef31520f5cb..40a2c81eebd 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'} -- cgit v1.2.1 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: 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 bb5bde9b08b..b767996bc82 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 fdbc84474ed..0bb815e64e7 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 bf352397509..40d39933ad8 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? } -- cgit v1.2.1 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: 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 +++++++------------------ app/assets/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 030ef3a60b7..704911aa13d 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 595b541379a..dca4dbb9f7d 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 829222509f0..c1a860b0d74 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 64b2725abfa..1ac11989d7f 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 752d8ec8788..670edb9300d 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 -- cgit v1.2.1 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: Fix race condition on auto merge --- CHANGELOG | 1 + app/controllers/projects/merge_requests_controller.rb | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bb5bde9b08b..7d34937a066 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 67e7187c10d..49b1f3cec32 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 -- cgit v1.2.1 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: 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 7d34937a066..15adf758477 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 49b1f3cec32..851822d805a 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 -- cgit v1.2.1 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: 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 e7471893d2e..68009e58645 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 d53ee3c45df..3ff8be5e284 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" -- cgit v1.2.1 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: 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 f57845ad9c9..2a1f0d1d87e 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; -- cgit v1.2.1 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: Moved pinned button to the bottom Changed breakpoint width to 1440px --- app/assets/javascripts/application.js.coffee | 2 +- app/assets/stylesheets/framework/gitlab-theme.scss | 7 ++++- app/assets/stylesheets/framework/sidebar.scss | 36 ++++++++++++++-------- app/assets/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 704911aa13d..bd835436a03 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 bb09de4121f..0a8603b6702 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 1ac11989d7f..281c0a0e1e9 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 670edb9300d..acada1f16a0 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 5442ee4efe3..8c140a5943e 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 90e872c461d..199ab3c38c3 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 -- cgit v1.2.1 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: 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 0e4cefc55c2..c85d23a31f0 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 f9ac16b32f3..47a2f2889d8 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 owner + .col-sm-10 + = 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 path + Project name .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.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true - if current_user.can_create_group? .help-block -- cgit v1.2.1 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: 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 a0aad66184d..5308e77fb19 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 -- cgit v1.2.1 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: 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 b916f880eeb..fa9cba510a7 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 -- cgit v1.2.1 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: 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 47a2f2889d8..7e8b8f83467 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 -- cgit v1.2.1 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: 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 c85d23a31f0..855d86cb238 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; -- cgit v1.2.1 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: Lighten each logo path color instead of white --- app/assets/stylesheets/framework/header.scss | 32 +++++++++++++++++++++---- app/assets/stylesheets/framework/sidebar.scss | 11 --------- app/assets/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 dca4dbb9f7d..78e6f5914a5 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 281c0a0e1e9..a0bb3427af0 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 acada1f16a0..148b00ac853 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: */ -- cgit v1.2.1 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: Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6533f2ea499..8556c368c44 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 -- cgit v1.2.1 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: 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 dde189a21d5..6fe57c737b3 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 { -- cgit v1.2.1 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: 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 02e024ca15a..8a7547e5322 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 -- cgit v1.2.1 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: Update CHANGELOG for 8.2.6 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index faf2e28eeb3..18fb2e6e1c1 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 -- cgit v1.2.1 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: Update CHANGELOG for 8.3.10 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 18fb2e6e1c1..8c4deceef97 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 -- cgit v1.2.1 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: Update CHANGELOG for 8.4.11 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 8c4deceef97..bbb4fdd135f 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 -- cgit v1.2.1 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: Update CHANGELOG for 8.5.13 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index bbb4fdd135f..b6758c7dfb3 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 -- cgit v1.2.1 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: Update CHANGELOG for 8.6.9 release [skip] --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index b6758c7dfb3..2a414c3ab41 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 -- cgit v1.2.1 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: Update CHANGELOG for 8.7.7 release [ci skip] --- CHANGELOG | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 2a414c3ab41..03b9178da3b 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) -- cgit v1.2.1 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: 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 ----------------------- spec/models/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 162c6723dd2..49b95dee484 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 5b21aac5e43..00000000000 --- 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 a3336c87173..903224589dd 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 7c617723e6d..7b4ccc83915 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 1634265b439..00000000000 --- 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 5309cfb99ff..c9517324541 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 09f0ee3871d..85dd30bf48c 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 } -- cgit v1.2.1 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: 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 ++++++---------------- .../profiles/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 a5b358f10f1..72d1b97bf56 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 86e08fed8e2..508b82a9a6c 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 65fe88e0287..388e2652ba9 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 d9f1e1c0acb..bb6f14a6225 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 456e770dc80..1b45548bd02 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 8c4a707e7ee..77e407b54c5 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 d824e1d288d..a85930c7543 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" -- cgit v1.2.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: 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 ++++++ app/assets/javascripts/blob/edit_blob.js.coffee | 5 +- .../javascripts/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 +++-- ...to_create_license_file_in_empty_project_spec.rb | 12 ++++- .../projects/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 cc8a497d081..8d0e3f363d1 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 00000000000..a719ba25122 --- /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 e17eaa75dc1..a3cc8dd844c 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 00000000000..68438733108 --- /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 79141e768b8..636f909dbd0 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 00000000000..e76e303189d --- /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 22679c764dc..a34b06f1054 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 85559fbc5f5..5b54b34070c 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 4071b59c003..ae89637df60 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 ecc818eb1e1..e1e105e6bbe 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 34eda29c285..67aac25e427 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 8550d279d09..6a39c302f55 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') -- cgit v1.2.1 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: 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 06997147b09..f22db61e744 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 -- cgit v1.2.1 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: 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 ++++++++++++ app/views/projects/merge_requests/_new_compare.html.haml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index fa0960b2847..354ee450ddc 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 b08524574e4..de39964fca8 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") -- cgit v1.2.1 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: CHANGELOG [ci skip] --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 354ee450ddc..e7c68a37af6 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 -- cgit v1.2.1 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: CHANGELOG [ci skip] --- CHANGELOG | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e7c68a37af6..c459e876900 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 -- cgit v1.2.1 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: Make project_id nullable --- .../20160616084004_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 00000000000..cc1daf9b621 --- /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 6a3be7297e3..d6a542a89fd 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" -- cgit v1.2.1 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: 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 b8dffe9f5b9..e66e04371b2 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 ccdcb29f773..2c19aa3f67f 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), -- cgit v1.2.1 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: Banzai::Filter::UploadLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/upload_link_filter.rb | 11 +++-------- spec/lib/banzai/filter/upload_link_filter_spec.rb | 20 ++++++++++++++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fa0960b2847..39532e88138 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 c0f503c9af3..45bb66dc99f 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 b83be54746c..273d2ed709a 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 -- cgit v1.2.1 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: 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 fa0960b2847..c9f745f6ddb 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 8fd27b1d88e..de2cdc6ecae 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'] -- cgit v1.2.1 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: 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 fdbc84474ed..9228ccab718 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 fedab1f913b..53c8408633c 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 -- cgit v1.2.1 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: 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 a22bd1b1f52..2b28cb0d916 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) -- cgit v1.2.1 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: 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. --- lib/banzai/filter/abstract_reference_filter.rb | 51 ++++++++++++++++++++- lib/banzai/filter/issue_reference_filter.rb | 31 ++++++++++++- .../lib/banzai/filter/abstract_link_filter_spec.rb | 52 ++++++++++++++++++++++ .../banzai/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 db95d7c908b..4815bafe238 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 2496e704002..2614261f9eb 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 00000000000..0c55d8e19da --- /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 8e6a264970d..25f0bc2092f 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 18692f1279a..f99ad046f0d 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 -- cgit v1.2.1 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: Banzai::Filter::ExternalLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/external_link_filter.rb | 13 ++------- .../lib/banzai/filter/external_link_filter_spec.rb | 34 +++++++++++++++------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 39532e88138..b886668d89d 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 f73ecfc9418..0a29c547a4d 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 f4c5c621bd0..695a5bc6fd4 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) - - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'nofollow' + context 'for root links on document' do + let(:doc) { filter %q(<a href="https://google.com/">Google</a>) } + + 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>) } + + 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 - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'noreferrer' + 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 -- cgit v1.2.1 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: 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 2ddc5d504fa..a3a4dba3fa4 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 cf65057e704..9577696fc0d 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 -- cgit v1.2.1 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: 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 78e6f5914a5..a7bcb456560 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; } } -- cgit v1.2.1 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: 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 03ca4be80da..27414381971 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 e5b277cb198..65d1bad511d 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 4afa902b4eb..e9ca46a74bf 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 -- cgit v1.2.1 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: 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 1a7af66fac4..435bccaf04b 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 2b28cb0d916..e5d0f8119dd 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) -- cgit v1.2.1 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: 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 00000000000..3349ee84807 --- /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 00000000000..95923d9f8de --- /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. -- cgit v1.2.1 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: Customize the Devise `reset_password_instructions` email --- app/views/devise/mailer/reset_password_instructions.html.erb | 8 -------- .../devise/mailer/reset_password_instructions.html.haml | 12 ++++++++++++ app/views/devise/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 23b31da92d8..00000000000 --- 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 00000000000..e91c9522520 --- /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 00000000000..116313ee11c --- /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) %> -- cgit v1.2.1 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: Customize the Devise `unlock_instructions` email --- app/views/devise/mailer/unlock_instructions.html.haml | 19 +++++++++---------- app/views/devise/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 52b327e20c5..9990d1ccac6 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 00000000000..3aea3e20145 --- /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) %> -- cgit v1.2.1 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: Add previews for all customized Devise emails --- spec/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 dc3062a4332..d6588efc486 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 -- cgit v1.2.1 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: 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 043eb5a0d1f..480c02f06c3 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 28611a5ec81..9495c5b3f37 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 { -- cgit v1.2.1 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: Update CHANGELOG for !4659 [ci skip] --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 058d4e4cb82..e699515f238 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 -- cgit v1.2.1 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: 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 ++-- lib/gitlab/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 f3ac35be81f..891a2276648 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 a66602f9194..325ab795def 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 3900fc89391..72f648975dc 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 143e2e6d238..b0b03146d0c 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 -- cgit v1.2.1 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: 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 764d8e4e136..d618c84e983 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 e3827cae9a6..7d6668920c0 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 -- cgit v1.2.1 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: 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 | 56 +++++++++++++++++++--------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index a66602f9194..ea296d05b2b 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 143e2e6d238..1c775c182f1 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'] - - config = YAML.dump( - { before_script: ['pwd'], - rspec: { - variables: variables, - script: 'rspec' } - }) + 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' } + }) + + expect { GitlabCiYamlProcessor.new(config, path) } + .to raise_error(GitlabCiYamlProcessor::ValidationError, + /job: variables should be a map/) + end + end - expect { GitlabCiYamlProcessor.new(config, path) } - .to raise_error(GitlabCiYamlProcessor::ValidationError, - /job: variables should be a map/) + 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 -- cgit v1.2.1 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: Add Changelog entry for CI job variables config fix --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index f3ac35be81f..a610b528446 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) -- cgit v1.2.1 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: 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 53bff508c72..e67271adfb1 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 { -- cgit v1.2.1