From 92d361c8d34d3ef29ff546ed1f7b74ce9a8f81f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sun, 11 Mar 2018 23:51:10 +0100 Subject: Add empty repo check before running AutoDevOps pipeline --- .../projects/pipelines_settings_controller.rb | 8 +++-- .../projects/pipelines_settings_controller_spec.rb | 34 ++++++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 06ce7328fb5..6ccefb7e562 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -11,8 +11,12 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." if service.run_auto_devops_pipeline? - CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) - flash[:success] = "A new Auto DevOps pipeline has been created, go to Pipelines page for details".html_safe + if @project.empty_repo? + flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." + else + CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) + flash[:success] = "A new Auto DevOps pipeline has been created, go to Pipelines page for details".html_safe + end end redirect_to project_settings_ci_cd_path(@project) diff --git a/spec/controllers/projects/pipelines_settings_controller_spec.rb b/spec/controllers/projects/pipelines_settings_controller_spec.rb index 1cc488bef32..0dd8575e2b7 100644 --- a/spec/controllers/projects/pipelines_settings_controller_spec.rb +++ b/spec/controllers/projects/pipelines_settings_controller_spec.rb @@ -47,10 +47,38 @@ describe Projects::PipelinesSettingsController do expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(true) end - it 'queues a CreatePipelineWorker' do - expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + context 'when the project repository is empty' do + before do + allow_any_instance_of(Project).to receive(:empty_repo?).and_return(true) + end - subject + it 'sets a warning flash' do + expect(subject).to set_flash[:warning] + end + + it 'does not queue a CreatePipelineWorker' do + expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + subject + end + end + + context 'when the project repository is not empty' do + before do + allow_any_instance_of(Project).to receive(:empty_repo?).and_return(false) + end + + it 'sets a success flash' do + allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + expect(subject).to set_flash[:success] + end + + it 'queues a CreatePipelineWorker' do + expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + subject + end end end -- cgit v1.2.1 From 1ff339fad3e63a7ec2961de596e7b0fe4272fae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sun, 11 Mar 2018 23:55:11 +0100 Subject: Add CHANGELOG --- ...g-auto-devops-on-an-empty-project-gives-you-wrong-information.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml diff --git a/changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml b/changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml new file mode 100644 index 00000000000..889fd008bad --- /dev/null +++ b/changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml @@ -0,0 +1,5 @@ +--- +title: Add empty repo check before running AutoDevOps pipeline +merge_request: 17605 +author: +type: changed -- cgit v1.2.1 From 5b91905321670dff48f0333d06cfce61414e97cc Mon Sep 17 00:00:00 2001 From: Tomasz Maczukin Date: Wed, 14 Mar 2018 16:34:21 +0100 Subject: Update CI services documnetation --- doc/ci/docker/using_docker_images.md | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index fb5bfe26bb0..2f336b1eb78 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -83,6 +83,90 @@ So, in order to access your database service you have to connect to the host named `mysql` instead of a socket or `localhost`. Read more in [accessing the services](#accessing-the-services). +### How service health check works + +Services are designed to provide additional functionality which is **network accessible**. +It may be a database (like mysql, but also like redis), this may be docker:dind (which +allows you to use Docker). This may be anything else that is required for the CI/CD job +to proceed and what is accessed by network. + +To make sure this works, Runner is: + +1. checking which ports are exposed from the container by default, +1. starts a special container that waits for these ports to be accessible. + +When the second stage of the check fails (either because there is no opened port in the +service, or service was not started properly before the timeout and the port is not +responding), it prints the warning: `*** WARNING: Service XYZ probably didn't start properly`. + +In most cases it will affect the job, but there may be situations when job still succeeds +even if such warning was printed, e.g.: + +- service was started a little after the warning was raised, and the job is using it not + from the very beginning - in that case when the job (e.g. tests) needed to access the + service, it may be already there waiting for connections, +- service container is not providing any networking service, but doing something with job's + directory (all services have the job directory mounted as a volume under `/builds`) - in + that case the service will do its job, and since tje job is not trying to connect to it, + it doesn't fail. + +### What services are not for + +As it was mentioned before, this feature is designed to provide **network accessible** +services. A database is the easiest example of such service. + +**Services feature is not designed to, and will not add any software from defined +service image to job's container.** + +For example, such definition: + +```yaml +job: + services: + - php:7 + - node:latest + - golang:1.10 + image: alpine:3.7 + script: + - php -v + - node -v + - go version +``` + +will not make `php`, `node` or `go` commands available for your script. So each of the +commands defined in `script:` section will fail. + +If you need to have `php`, `node` and `go` available for your script, you should either: + +- choose existing Docker image that contain all required tools, or +- choose the best existing Docker image that fits into your requirements and create + your own one, adding all missing tools on top of it. + +Looking at the example above, to make the job working as expected we should first +create an image, let's call it `my-php-node-go-image`, basing on Dockerfile like: + +```Dockerfile +FROM alpine:3.7 + +RUN command-to-install-php +RUN command-to-install-node +RUN command-to-install-golang +``` + +and then change the definition in `.gitlab-ci.yml` file to: + +```yaml +job: + image: my-php-node-go-image + script: + - php -v + - node -v + - go version +``` + +This time all required tools are available in job's container, so each of the +commands defined in `script:` section will eventualy succeed. + ### Accessing the services Let's say that you need a Wordpress instance to test some API integration with -- cgit v1.2.1 From af6c16a80182f94da0eb98a7d49e5ecfbdccaf74 Mon Sep 17 00:00:00 2001 From: Tomasz Maczukin Date: Wed, 14 Mar 2018 16:38:34 +0100 Subject: Add CHANGELOG entry --- changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml diff --git a/changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml b/changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml new file mode 100644 index 00000000000..c76495ec959 --- /dev/null +++ b/changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml @@ -0,0 +1,5 @@ +--- +title: Update CI services documnetation +merge_request: 17749 +author: +type: other -- cgit v1.2.1 From 3eca161369bba3adf9eed899632f3baff5fbf64c Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Thu, 15 Mar 2018 09:50:16 +1100 Subject: [Rails5] Add Gemfile.rails5 --- Gemfile | 23 +- Gemfile.rails5 | 7 + Gemfile.rails5.lock | 1230 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1255 insertions(+), 5 deletions(-) create mode 100644 Gemfile.rails5 create mode 100644 Gemfile.rails5.lock diff --git a/Gemfile b/Gemfile index 2793463fd81..4a423e6b8b3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,19 @@ +# --- Special code for migrating to Rails 5.0 --- +def rails5? + %w[1 true].include?(ENV["RAILS5"]) +end + +gem_versions = {} +gem_versions['activerecord_sane_schema_dumper'] = rails5? ? '1.0' : '0.2' +gem_versions['default_value_for'] = rails5? ? '~> 3.0.5' : '~> 3.0.0' +gem_versions['html-pipeline'] = rails5? ? '~> 2.6.0' : '~> 1.11.0' +gem_versions['rails'] = rails5? ? '5.0.6' : '4.2.10' +gem_versions['rails-i18n'] = rails5? ? '~> 5.1' : '~> 4.0.9' +# --- The end of special code for migrating to Rails 5.0 --- + source 'https://rubygems.org' -gem 'rails', '4.2.10' +gem 'rails', gem_versions['rails'] gem 'rails-deprecated_sanitizer', '~> 1.0.3' # Responders respond_to and respond_with @@ -9,7 +22,7 @@ gem 'responders', '~> 2.0' gem 'sprockets', '~> 3.7.0' # Default values for AR models -gem 'default_value_for', '~> 3.0.0' +gem 'default_value_for', gem_versions['default_value_for'] # Supported DBs gem 'mysql2', '~> 0.4.10', group: :mysql @@ -122,7 +135,7 @@ gem 'unf', '~> 0.1.4' gem 'seed-fu', '~> 2.3.7' # Markdown and HTML processing -gem 'html-pipeline', '~> 1.11.0' +gem 'html-pipeline', gem_versions['html-pipeline'] gem 'deckar01-task_list', '2.0.0' gem 'gitlab-markup', '~> 1.6.2' gem 'redcarpet', '~> 3.4' @@ -270,7 +283,7 @@ gem 'premailer-rails', '~> 1.9.7' # I18n gem 'ruby_parser', '~> 3.8', require: false -gem 'rails-i18n', '~> 4.0.9' +gem 'rails-i18n', gem_versions['rails-i18n'] gem 'gettext_i18n_rails', '~> 1.8.0' gem 'gettext_i18n_rails_js', '~> 1.2.0' gem 'gettext', '~> 3.2.2', require: false, group: :development @@ -362,7 +375,7 @@ group :development, :test do gem 'license_finder', '~> 3.1', require: false gem 'knapsack', '~> 1.11.0' - gem 'activerecord_sane_schema_dumper', '0.2' + gem 'activerecord_sane_schema_dumper', gem_versions['activerecord_sane_schema_dumper'] gem 'stackprof', '~> 0.2.10', require: false diff --git a/Gemfile.rails5 b/Gemfile.rails5 new file mode 100644 index 00000000000..2b526b19ba0 --- /dev/null +++ b/Gemfile.rails5 @@ -0,0 +1,7 @@ +# BUNDLE_GEMFILE=Gemfile.rails5 bundle install + +ENV["RAILS5"] = "true" + +gemfile = File.expand_path("../Gemfile", __FILE__) + +eval(File.read(gemfile), nil, gemfile) diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock new file mode 100644 index 00000000000..85bf28ef8dd --- /dev/null +++ b/Gemfile.rails5.lock @@ -0,0 +1,1230 @@ +GEM + remote: https://rubygems.org/ + specs: + RedCloth (4.3.2) + abstract_type (0.0.7) + ace-rails-ap (4.1.4) + actioncable (5.0.6) + actionpack (= 5.0.6) + nio4r (>= 1.2, < 3.0) + websocket-driver (~> 0.6.1) + actionmailer (5.0.6) + actionpack (= 5.0.6) + actionview (= 5.0.6) + activejob (= 5.0.6) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (5.0.6) + actionview (= 5.0.6) + activesupport (= 5.0.6) + rack (~> 2.0) + rack-test (~> 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (5.0.6) + activesupport (= 5.0.6) + builder (~> 3.1) + erubis (~> 2.7.0) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.3) + activejob (5.0.6) + activesupport (= 5.0.6) + globalid (>= 0.3.6) + activemodel (5.0.6) + activesupport (= 5.0.6) + activerecord (5.0.6) + activemodel (= 5.0.6) + activesupport (= 5.0.6) + arel (~> 7.0) + activerecord_sane_schema_dumper (1.0) + rails (>= 5, < 6) + activesupport (5.0.6) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) + acts-as-taggable-on (4.0.0) + activerecord (>= 4.0) + adamantium (0.2.0) + ice_nine (~> 0.11.0) + memoizable (~> 0.4.0) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) + aes_key_wrap (1.0.1) + akismet (2.0.0) + allocations (1.0.5) + arel (7.1.4) + asana (0.6.3) + faraday (~> 0.9) + faraday_middleware (~> 0.9) + faraday_middleware-multi_json (~> 0.0) + oauth2 (~> 1.0) + asciidoctor (1.5.6.1) + asciidoctor-plantuml (0.0.7) + asciidoctor (~> 1.5) + asset_sync (2.2.0) + activemodel (>= 4.1.0) + fog-core + mime-types (>= 2.99) + unf + ast (2.4.0) + atomic (1.1.100) + attr_encrypted (3.0.3) + encryptor (~> 3.0.0) + attr_required (1.0.1) + autoprefixer-rails (8.1.0.1) + execjs + awesome_print (1.2.0) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) + babosa (1.0.2) + base32 (0.3.2) + batch-loader (1.2.1) + bcrypt (3.1.11) + bcrypt_pbkdf (1.0.0) + benchmark-ips (2.3.0) + better_errors (2.1.1) + coderay (>= 1.0.0) + erubis (>= 2.6.6) + rack (>= 0.9.0) + bindata (2.4.3) + binding_of_caller (0.7.3) + debug_inspector (>= 0.0.1) + blankslate (2.1.2.4) + bootstrap-sass (3.3.7) + autoprefixer-rails (>= 5.2.1) + sass (>= 3.3.4) + bootstrap_form (2.7.0) + brakeman (3.6.2) + browser (2.5.3) + builder (3.2.3) + bullet (5.5.1) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.10.0) + bundler-audit (0.5.0) + bundler (~> 1.2) + thor (~> 0.18) + byebug (9.0.6) + capybara (2.18.0) + addressable + mini_mime (>= 0.1.3) + nokogiri (>= 1.3.3) + rack (>= 1.0.0) + rack-test (>= 0.5.4) + xpath (>= 2.0, < 4.0) + capybara-screenshot (1.0.18) + capybara (>= 1.0, < 3) + launchy + carrierwave (1.2.2) + activemodel (>= 4.0.0) + activesupport (>= 4.0.0) + mime-types (>= 1.16) + charlock_holmes (0.7.5) + childprocess (0.9.0) + ffi (~> 1.0, >= 1.0.11) + chronic (0.10.2) + chronic_duration (0.10.6) + numerizer (~> 0.1.1) + chunky_png (1.3.10) + citrus (3.0.2) + coderay (1.1.2) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) + colorize (0.8.1) + commonmarker (0.17.9) + ruby-enum (~> 0.5) + concord (0.1.5) + adamantium (~> 0.2.0) + equalizer (~> 0.0.9) + concurrent-ruby (1.0.5) + concurrent-ruby-ext (1.0.5) + concurrent-ruby (= 1.0.5) + connection_pool (2.2.1) + crack (0.4.3) + safe_yaml (~> 1.0.0) + creole (0.5.0) + css_parser (1.6.0) + addressable + d3_rails (3.5.17) + railties (>= 3.1.0) + daemons (1.2.6) + database_cleaner (1.5.3) + debug_inspector (0.0.3) + debugger-ruby_core_source (1.3.8) + deckar01-task_list (2.0.0) + html-pipeline + declarative (0.0.10) + declarative-option (0.1.0) + default_value_for (3.0.5) + activerecord (>= 3.2.0, < 5.2) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) + devise (4.4.1) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0, < 5.2) + responders + warden (~> 1.2.3) + devise-two-factor (3.0.2) + activesupport (< 5.2) + attr_encrypted (>= 1.3, < 4, != 2) + devise (~> 4.0) + railties (< 5.2) + rotp (~> 2.0) + diff-lcs (1.3) + diffy (3.1.0) + docile (1.1.5) + domain_name (0.5.20170404) + unf (>= 0.0.5, < 1.0.0) + doorkeeper (4.2.6) + railties (>= 4.2) + doorkeeper-openid_connect (1.2.0) + doorkeeper (~> 4.0) + json-jwt (~> 1.6) + dropzonejs-rails (0.7.4) + rails (> 3.1) + email_reply_trimmer (0.1.10) + email_spec (1.6.0) + launchy (~> 2.1) + mail (~> 2.2) + encryptor (3.0.0) + equalizer (0.0.11) + erubis (2.7.0) + escape_utils (1.1.1) + et-orbi (1.0.9) + tzinfo + eventmachine (1.2.5) + excon (0.60.0) + execjs (2.7.0) + expression_parser (0.9.0) + factory_bot (4.8.2) + activesupport (>= 3.0.0) + factory_bot_rails (4.8.2) + factory_bot (~> 4.8.2) + railties (>= 3.0.0) + faraday (0.12.2) + multipart-post (>= 1.2, < 3) + faraday_middleware (0.12.2) + faraday (>= 0.7.4, < 1.0) + faraday_middleware-multi_json (0.0.6) + faraday_middleware + multi_json + fast_blank (1.0.0) + fast_gettext (1.6.0) + ffaker (2.8.1) + ffi (1.9.23) + flay (2.10.0) + erubis (~> 2.7.0) + path_expander (~> 1.0) + ruby_parser (~> 3.0) + sexp_processor (~> 4.0) + flipper (0.11.0) + flipper-active_record (0.11.0) + activerecord (>= 3.2, < 6) + flipper (~> 0.11.0) + flipper-active_support_cache_store (0.11.0) + activesupport (>= 3.2, < 6) + flipper (~> 0.11.0) + flowdock (0.7.1) + httparty (~> 0.7) + multi_json + fog-aliyun (0.2.0) + fog-core (~> 1.27) + fog-json (~> 1.0) + ipaddress (~> 0.8) + xml-simple (~> 1.1) + fog-aws (1.4.1) + fog-core (~> 1.38) + fog-json (~> 1.0) + fog-xml (~> 0.1) + ipaddress (~> 0.8) + fog-core (1.45.0) + builder + excon (~> 0.58) + formatador (~> 0.2) + fog-google (0.6.0) + fog-core + fog-json + fog-xml + fog-json (1.0.2) + fog-core (~> 1.0) + multi_json (~> 1.10) + fog-local (0.5.0) + fog-core (>= 1.27, < 3.0) + fog-openstack (0.1.24) + fog-core (~> 1.40) + fog-json (>= 1.0) + ipaddress (>= 0.8) + fog-rackspace (0.1.5) + fog-core (>= 1.35) + fog-json (>= 1.0) + fog-xml (>= 0.1) + ipaddress (>= 0.8) + fog-xml (0.1.3) + fog-core + nokogiri (>= 1.5.11, < 2.0.0) + font-awesome-rails (4.7.0.3) + railties (>= 3.2, < 5.2) + foreman (0.78.0) + thor (~> 0.19.1) + formatador (0.2.5) + fuubar (2.2.0) + rspec-core (~> 3.0) + ruby-progressbar (~> 1.4) + gemnasium-gitlab-service (0.2.6) + rugged (~> 0.21) + gemojione (3.3.0) + json + get_process_mem (0.2.1) + gettext (3.2.9) + locale (>= 2.0.5) + text (>= 1.3.0) + gettext_i18n_rails (1.8.0) + fast_gettext (>= 0.9.0) + gettext_i18n_rails_js (1.2.0) + gettext (>= 3.0.2) + gettext_i18n_rails (>= 0.7.1) + po_to_json (>= 1.0.0) + rails (>= 3.2.0) + gherkin-ruby (0.3.2) + gitaly-proto (0.88.0) + google-protobuf (~> 3.1) + grpc (~> 1.0) + github-linguist (5.3.3) + charlock_holmes (~> 0.7.5) + escape_utils (~> 1.1.0) + mime-types (>= 1.19) + rugged (>= 0.25.1) + github-markup (1.7.0) + gitlab-flowdock-git-hook (1.0.1) + flowdock (~> 0.7) + gitlab-grit (>= 2.4.1) + multi_json + gitlab-grit (2.8.2) + charlock_holmes (~> 0.6) + diff-lcs (~> 1.1) + mime-types (>= 1.16) + posix-spawn (~> 0.3) + gitlab-markup (1.6.3) + gitlab-styles (2.3.2) + rubocop (~> 0.51) + rubocop-gitlab-security (~> 0.1.0) + rubocop-rspec (~> 1.19) + gitlab_omniauth-ldap (2.0.4) + net-ldap (~> 0.16) + omniauth (~> 1.3) + pyu-ruby-sasl (>= 0.0.3.3, < 0.1) + rubyntlm (~> 0.5) + globalid (0.4.1) + activesupport (>= 4.2.0) + gollum-grit_adapter (1.0.1) + gitlab-grit (~> 2.7, >= 2.7.1) + gollum-lib (4.2.7) + gemojione (~> 3.2) + github-markup (~> 1.6) + gollum-grit_adapter (~> 1.0) + nokogiri (>= 1.6.1, < 2.0) + rouge (~> 2.1) + sanitize (~> 2.1) + stringex (~> 2.6) + gollum-rugged_adapter (0.4.4) + mime-types (>= 1.15) + rugged (~> 0.25) + gon (6.1.0) + actionpack (>= 3.0) + json + multi_json + request_store (>= 1.0) + google-api-client (0.13.6) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 0.5) + httpclient (>= 2.8.1, < 3.0) + mime-types (~> 3.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + google-protobuf (3.5.1) + googleapis-common-protos-types (1.0.1) + google-protobuf (~> 3.0) + googleauth (0.6.2) + faraday (~> 0.12) + jwt (>= 1.4, < 3.0) + logging (~> 2.0) + memoist (~> 0.12) + multi_json (~> 1.11) + os (~> 0.9) + signet (~> 0.7) + gpgme (2.0.16) + mini_portile2 (~> 2.3) + grape (1.0.2) + activesupport + builder + mustermann-grape (~> 1.0.0) + rack (>= 1.3.0) + rack-accept + virtus (>= 1.0.0) + grape-entity (0.6.1) + activesupport (>= 5.0.0) + multi_json (>= 1.3.2) + grape-route-helpers (2.1.0) + activesupport + grape (>= 0.16.0) + rake + grape_logging (1.7.0) + grape + grpc (1.10.0) + google-protobuf (~> 3.1) + googleapis-common-protos-types (~> 1.0.0) + googleauth (>= 0.5.1, < 0.7) + haml (4.0.7) + tilt + haml_lint (0.26.0) + haml (>= 4.0, < 5.1) + rainbow + rake (>= 10, < 13) + rubocop (>= 0.49.0) + sysexits (~> 1.1) + hamlit (2.6.2) + temple (~> 0.7.6) + thor + tilt + hashdiff (0.3.7) + hashie (3.5.7) + hashie-forbidden_attributes (0.1.1) + hashie (>= 3.0) + health_check (2.6.0) + rails (>= 4.0) + hipchat (1.5.4) + httparty + mimemagic + html-pipeline (2.6.0) + activesupport (>= 2) + nokogiri (>= 1.4) + html2text (0.2.1) + nokogiri (~> 1.6) + htmlentities (4.3.4) + http (0.9.8) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 1.0.1) + http_parser.rb (~> 0.6.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + http-form_data (1.0.3) + http_parser.rb (0.6.0) + httparty (0.13.7) + json (~> 1.8) + multi_xml (>= 0.5.2) + httpclient (2.8.3) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + influxdb (0.5.3) + ipaddress (0.8.3) + jira-ruby (1.5.0) + activesupport + multipart-post + oauth (~> 0.5, >= 0.5.0) + jquery-atwho-rails (1.3.2) + jquery-rails (4.3.1) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) + json (1.8.6) + json-jwt (1.9.2) + activesupport + aes_key_wrap + bindata + securecompare + url_safe_base64 + json-schema (2.8.0) + addressable (>= 2.4) + jwt (1.5.6) + kaminari (1.1.1) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.1.1) + kaminari-activerecord (= 1.1.1) + kaminari-core (= 1.1.1) + kaminari-actionview (1.1.1) + actionview + kaminari-core (= 1.1.1) + kaminari-activerecord (1.1.1) + activerecord + kaminari-core (= 1.1.1) + kaminari-core (1.1.1) + kgio (2.11.2) + knapsack (1.11.1) + rake + timecop (>= 0.1.0) + kubeclient (2.2.0) + http (= 0.9.8) + recursive-open-struct (= 1.0.0) + rest-client + launchy (2.4.3) + addressable (~> 2.3) + letter_opener (1.6.0) + launchy (~> 2.2) + letter_opener_web (1.3.3) + actionmailer (>= 3.2) + letter_opener (~> 1.0) + railties (>= 3.2) + license_finder (3.1.1) + bundler + httparty + rubyzip + thor + toml (= 0.1.2) + with_env (> 1.0) + xml-simple + licensee (8.7.0) + rugged (~> 0.24) + little-plugger (1.1.4) + locale (2.1.2) + logging (2.2.2) + little-plugger (~> 1.1) + multi_json (~> 1.10) + lograge (0.9.0) + actionpack (>= 4) + activesupport (>= 4) + railties (>= 4) + request_store (~> 1.0) + loofah (2.0.3) + nokogiri (>= 1.5.9) + mail (2.7.0) + mini_mime (>= 0.1.1) + mail_room (0.9.1) + memoist (0.16.0) + memoizable (0.4.2) + thread_safe (~> 0.3, >= 0.3.1) + method_source (0.9.0) + mime-types (3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0521) + mimemagic (0.3.2) + mini_mime (1.0.0) + mini_portile2 (2.3.0) + minitest (5.7.0) + mousetrap-rails (1.4.6) + multi_json (1.13.1) + multi_xml (0.6.0) + multipart-post (2.0.0) + mustermann (1.0.2) + mustermann-grape (1.0.0) + mustermann (~> 1.0.0) + mysql2 (0.4.10) + net-ldap (0.16.1) + net-ssh (4.1.0) + netrc (0.11.0) + nio4r (2.2.0) + nokogiri (1.8.2) + mini_portile2 (~> 2.3.0) + numerizer (0.1.1) + oauth (0.5.4) + oauth2 (1.4.0) + faraday (>= 0.8, < 0.13) + jwt (~> 1.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 3) + octokit (4.6.2) + sawyer (~> 0.8.0, >= 0.5.3) + oj (2.17.5) + omniauth (1.4.3) + hashie (>= 1.2, < 4) + rack (>= 1.6.2, < 3) + omniauth-auth0 (1.4.2) + omniauth-oauth2 (~> 1.1) + omniauth-authentiq (0.3.1) + omniauth-oauth2 (~> 1.3, >= 1.3.1) + omniauth-azure-oauth2 (0.0.9) + jwt (~> 1.0) + omniauth (~> 1.0) + omniauth-oauth2 (~> 1.4) + omniauth-cas3 (1.1.4) + addressable (~> 2.3) + nokogiri (~> 1.7, >= 1.7.1) + omniauth (~> 1.2) + omniauth-facebook (4.0.0) + omniauth-oauth2 (~> 1.2) + omniauth-github (1.1.2) + omniauth (~> 1.0) + omniauth-oauth2 (~> 1.1) + omniauth-gitlab (1.0.3) + omniauth (~> 1.0) + omniauth-oauth2 (~> 1.0) + omniauth-google-oauth2 (0.5.3) + jwt (>= 1.5) + omniauth (>= 1.1.1) + omniauth-oauth2 (>= 1.5) + omniauth-kerberos (0.3.0) + omniauth-multipassword + timfel-krb5-auth (~> 0.8) + omniauth-multipassword (0.4.2) + omniauth (~> 1.0) + omniauth-oauth (1.1.0) + oauth + omniauth (~> 1.0) + omniauth-oauth2 (1.5.0) + oauth2 (~> 1.1) + omniauth (~> 1.2) + omniauth-oauth2-generic (0.2.4) + omniauth-oauth2 (~> 1.0) + omniauth-saml (1.7.0) + omniauth (~> 1.3) + ruby-saml (~> 1.4) + omniauth-shibboleth (1.2.1) + omniauth (>= 1.0.0) + omniauth-twitter (1.2.1) + json (~> 1.3) + omniauth-oauth (~> 1.1) + omniauth_crowd (2.2.3) + activesupport + nokogiri (>= 1.4.4) + omniauth (~> 1.0) + org-ruby (0.9.12) + rubypants (~> 0.2) + orm_adapter (0.5.0) + os (0.9.6) + parallel (1.12.1) + parser (2.5.0.4) + ast (~> 2.4.0) + parslet (1.5.0) + blankslate (~> 2.0) + path_expander (1.0.2) + peek (1.0.1) + concurrent-ruby (>= 0.9.0) + concurrent-ruby-ext (>= 0.9.0) + railties (>= 4.0.0) + peek-gc (0.0.2) + peek + peek-host (1.0.0) + peek + peek-mysql2 (1.1.0) + atomic (>= 1.0.0) + mysql2 + peek + peek-performance_bar (1.3.1) + peek (>= 0.1.0) + peek-pg (1.3.0) + concurrent-ruby + concurrent-ruby-ext + peek + pg + peek-rblineprof (0.2.0) + peek + rblineprof + peek-redis (1.2.0) + atomic (>= 1.0.0) + peek + redis + peek-sidekiq (1.0.3) + atomic (>= 1.0.0) + peek + sidekiq + pg (0.18.4) + po_to_json (1.0.1) + json (>= 1.6.0) + posix-spawn (0.3.13) + powerpack (0.1.1) + premailer (1.11.1) + addressable + css_parser (>= 1.6.0) + htmlentities (>= 4.0.0) + premailer-rails (1.9.7) + actionmailer (>= 3, < 6) + premailer (~> 1.7, >= 1.7.9) + proc_to_ast (0.1.0) + coderay + parser + unparser + procto (0.0.3) + prometheus-client-mmap (0.9.1) + pry (0.11.3) + coderay (~> 1.1.0) + method_source (~> 0.9.0) + pry-byebug (3.4.3) + byebug (>= 9.0, < 9.1) + pry (~> 0.10) + pry-rails (0.3.6) + pry (>= 0.10.4) + public_suffix (3.0.2) + pyu-ruby-sasl (0.0.3.3) + rack (2.0.4) + rack-accept (0.4.5) + rack (>= 0.4) + rack-attack (4.4.1) + rack + rack-cors (1.0.2) + rack-oauth2 (1.2.3) + activesupport (>= 2.3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) + multi_json (>= 1.3.6) + rack (>= 1.1) + rack-protection (2.0.1) + rack + rack-proxy (0.6.4) + rack + rack-test (0.6.3) + rack (>= 1.0) + rails (5.0.6) + actioncable (= 5.0.6) + actionmailer (= 5.0.6) + actionpack (= 5.0.6) + actionview (= 5.0.6) + activejob (= 5.0.6) + activemodel (= 5.0.6) + activerecord (= 5.0.6) + activesupport (= 5.0.6) + bundler (>= 1.3.0) + railties (= 5.0.6) + sprockets-rails (>= 2.0.0) + rails-deprecated_sanitizer (1.0.3) + activesupport (>= 4.2.0.alpha) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.0.3) + loofah (~> 2.0) + rails-i18n (5.1.1) + i18n (>= 0.7, < 2) + railties (>= 5.0, < 6) + railties (5.0.6) + actionpack (= 5.0.6) + activesupport (= 5.0.6) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + rainbow (2.2.2) + rake + raindrops (0.19.0) + rake (12.3.0) + rb-fsevent (0.10.3) + rb-inotify (0.9.10) + ffi (>= 0.5.0, < 2) + rblineprof (0.3.7) + debugger-ruby_core_source (~> 1.3) + rbnacl (4.0.2) + ffi + rbnacl-libsodium (1.0.16) + rbnacl (>= 3.0.1) + rdoc (4.3.0) + re2 (1.1.1) + recaptcha (3.4.0) + json + recursive-open-struct (1.0.0) + redcarpet (3.4.0) + redis (3.3.5) + redis-actionpack (5.0.2) + actionpack (>= 4.0, < 6) + redis-rack (>= 1, < 3) + redis-store (>= 1.1.0, < 2) + redis-activesupport (5.0.4) + activesupport (>= 3, < 6) + redis-store (>= 1.3, < 2) + redis-namespace (1.5.3) + redis (~> 3.0, >= 3.0.4) + redis-rack (2.0.4) + rack (>= 1.5, < 3) + redis-store (>= 1.2, < 2) + redis-rails (5.0.2) + redis-actionpack (>= 5.0, < 6) + redis-activesupport (>= 5.0, < 6) + redis-store (>= 1.2, < 2) + redis-store (1.4.1) + redis (>= 2.2, < 5) + representable (3.0.4) + declarative (< 0.1.0) + declarative-option (< 0.2.0) + uber (< 0.2.0) + request_store (1.4.0) + rack (>= 1.4) + responders (2.4.0) + actionpack (>= 4.2.0, < 5.3) + railties (>= 4.2.0, < 5.3) + rest-client (2.0.2) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + retriable (3.1.1) + rinku (2.0.4) + rotp (2.1.2) + rouge (2.2.1) + rqrcode (0.10.1) + chunky_png (~> 1.0) + rqrcode-rails3 (0.1.7) + rqrcode (>= 0.4.2) + rspec (3.6.0) + rspec-core (~> 3.6.0) + rspec-expectations (~> 3.6.0) + rspec-mocks (~> 3.6.0) + rspec-core (3.6.0) + rspec-support (~> 3.6.0) + rspec-expectations (3.6.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.6.0) + rspec-mocks (3.6.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.6.0) + rspec-parameterized (0.4.0) + binding_of_caller + parser + proc_to_ast + rspec (>= 2.13, < 4) + unparser + rspec-rails (3.6.1) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 3.6.0) + rspec-expectations (~> 3.6.0) + rspec-mocks (~> 3.6.0) + rspec-support (~> 3.6.0) + rspec-retry (0.4.6) + rspec-core + rspec-set (0.1.3) + rspec-support (3.6.0) + rspec_profiling (0.0.5) + activerecord + pg + rails + sqlite3 + rubocop (0.52.1) + parallel (~> 1.10) + parser (>= 2.4.0.2, < 3.0) + powerpack (~> 0.1) + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + rubocop-gitlab-security (0.1.1) + rubocop (>= 0.51) + rubocop-rspec (1.22.2) + rubocop (>= 0.52.1) + ruby-enum (0.7.2) + i18n + ruby-fogbugz (0.2.1) + crack (~> 0.4) + ruby-prof (0.16.2) + ruby-progressbar (1.9.0) + ruby-saml (1.7.2) + nokogiri (>= 1.5.10) + ruby_parser (3.11.0) + sexp_processor (~> 4.9) + rubyntlm (0.6.2) + rubypants (0.7.0) + rubyzip (1.2.1) + rufus-scheduler (3.4.2) + et-orbi (~> 1.0) + rugged (0.26.0) + safe_yaml (1.0.4) + sanitize (2.1.0) + nokogiri (>= 1.4.4) + sass (3.5.5) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + sass-rails (5.0.7) + railties (>= 4.0.0, < 6) + sass (~> 3.1) + sprockets (>= 2.8, < 4.0) + sprockets-rails (>= 2.0, < 4.0) + tilt (>= 1.1, < 3) + sawyer (0.8.1) + addressable (>= 2.3.5, < 2.6) + faraday (~> 0.8, < 1.0) + scss_lint (0.56.0) + rake (>= 0.9, < 13) + sass (~> 3.5.3) + securecompare (1.0.0) + seed-fu (2.3.7) + activerecord (>= 3.1) + activesupport (>= 3.1) + select2-rails (3.5.10) + thor (~> 0.14) + selenium-webdriver (3.11.0) + childprocess (~> 0.5) + rubyzip (~> 1.2) + sentry-raven (2.5.3) + faraday (>= 0.7.6, < 1.0) + settingslogic (2.0.9) + sexp_processor (4.10.1) + sham_rack (1.3.6) + rack + shoulda-matchers (3.1.2) + activesupport (>= 4.0.0) + sidekiq (5.1.1) + concurrent-ruby (~> 1.0) + connection_pool (~> 2.2, >= 2.2.0) + rack-protection (>= 1.5.0) + redis (>= 3.3.5, < 5) + sidekiq-cron (0.6.3) + rufus-scheduler (>= 3.3.0) + sidekiq (>= 4.2.1) + sidekiq-limit_fetch (3.4.0) + sidekiq (>= 4) + signet (0.8.1) + addressable (~> 2.3) + faraday (~> 0.9) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simple_po_parser (1.1.3) + simplecov (0.14.1) + docile (~> 1.1.0) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + slack-notifier (1.5.1) + spinach (0.10.1) + colorize + gherkin-ruby (>= 0.3.2) + json + spinach-rails (0.2.1) + capybara (>= 2.0.0) + railties (>= 3) + spinach (>= 0.4) + spinach-rerun-reporter (0.0.2) + spinach (~> 0.8) + spring (2.0.2) + activesupport (>= 4.2) + spring-commands-rspec (1.0.4) + spring (>= 0.9.1) + spring-commands-spinach (1.1.0) + spring (>= 0.9.1) + sprockets (3.7.1) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.2.1) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) + sqlite3 (1.3.13) + sshkey (1.9.0) + stackprof (0.2.11) + state_machines (0.5.0) + state_machines-activemodel (0.5.0) + activemodel (>= 4.1, < 5.2) + state_machines (>= 0.5.0) + state_machines-activerecord (0.4.1) + activerecord (>= 4.1, < 5.2) + state_machines-activemodel (>= 0.3.0) + stringex (2.8.4) + sys-filesystem (1.1.9) + ffi + sysexits (1.2.0) + temple (0.7.7) + test-prof (0.2.5) + test_after_commit (1.1.0) + activerecord (>= 3.2) + text (1.3.1) + thin (1.7.2) + daemons (~> 1.0, >= 1.0.9) + eventmachine (~> 1.0, >= 1.0.4) + rack (>= 1, < 3) + thor (0.19.4) + thread_safe (0.3.6) + tilt (2.0.8) + timecop (0.8.1) + timfel-krb5-auth (0.8.3) + toml (0.1.2) + parslet (~> 1.5.0) + toml-rb (1.0.0) + citrus (~> 3.0, > 3.0) + truncato (0.7.10) + htmlentities (~> 4.3.1) + nokogiri (~> 1.8.0, >= 1.7.0) + tzinfo (1.2.5) + thread_safe (~> 0.1) + u2f (0.2.1) + uber (0.1.0) + uglifier (2.7.2) + execjs (>= 0.3.0) + json (>= 1.8.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.5) + unicode-display_width (1.3.0) + unicorn (5.1.0) + kgio (~> 2.6) + raindrops (~> 0.7) + unicorn-worker-killer (0.4.4) + get_process_mem (~> 0) + unicorn (>= 4, < 6) + uniform_notifier (1.10.0) + unparser (0.2.7) + abstract_type (~> 0.0.7) + adamantium (~> 0.2.0) + concord (~> 0.1.5) + diff-lcs (~> 1.3) + equalizer (~> 0.0.9) + parser (>= 2.3.1.2, < 2.6) + procto (~> 0.0.2) + url_safe_base64 (0.2.2) + validates_hostname (1.0.8) + activerecord (>= 3.0) + activesupport (>= 3.0) + version_sorter (2.1.0) + virtus (1.0.5) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + equalizer (~> 0.0, >= 0.0.9) + vmstat (2.3.0) + warden (1.2.7) + rack (>= 1.0) + webmock (2.3.2) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff + webpack-rails (0.9.11) + railties (>= 3.2.0) + websocket-driver (0.6.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.3) + wikicloth (0.8.1) + builder + expression_parser + rinku + with_env (1.1.0) + xml-simple (1.1.5) + xpath (3.0.0) + nokogiri (~> 1.8) + +PLATFORMS + ruby + +DEPENDENCIES + RedCloth (~> 4.3.2) + ace-rails-ap (~> 4.1.0) + activerecord_sane_schema_dumper (= 1.0) + acts-as-taggable-on (~> 4.0) + addressable (~> 2.5.2) + akismet (~> 2.0) + allocations (~> 1.0) + asana (~> 0.6.0) + asciidoctor (~> 1.5.2) + asciidoctor-plantuml (= 0.0.7) + asset_sync (~> 2.2.0) + attr_encrypted (~> 3.0.0) + awesome_print (~> 1.2.0) + babosa (~> 1.0.2) + base32 (~> 0.3.0) + batch-loader (~> 1.2.1) + bcrypt_pbkdf (~> 1.0) + benchmark-ips (~> 2.3.0) + better_errors (~> 2.1.0) + binding_of_caller (~> 0.7.2) + bootstrap-sass (~> 3.3.0) + bootstrap_form (~> 2.7.0) + brakeman (~> 3.6.0) + browser (~> 2.2) + bullet (~> 5.5.0) + bundler-audit (~> 0.5.0) + capybara (~> 2.15) + capybara-screenshot (~> 1.0.0) + carrierwave (~> 1.2) + charlock_holmes (~> 0.7.5) + chronic (~> 0.10.2) + chronic_duration (~> 0.10.6) + commonmarker (~> 0.17) + concurrent-ruby (~> 1.0.5) + connection_pool (~> 2.0) + creole (~> 0.5.0) + d3_rails (~> 3.5.0) + database_cleaner (~> 1.5.0) + deckar01-task_list (= 2.0.0) + default_value_for (~> 3.0.5) + devise (~> 4.2) + devise-two-factor (~> 3.0.0) + diffy (~> 3.1.0) + doorkeeper (~> 4.2.0) + doorkeeper-openid_connect (~> 1.2.0) + dropzonejs-rails (~> 0.7.1) + email_reply_trimmer (~> 0.1) + email_spec (~> 1.6.0) + factory_bot_rails (~> 4.8.2) + faraday (~> 0.12) + fast_blank + ffaker (~> 2.4) + flay (~> 2.10.0) + flipper (~> 0.11.0) + flipper-active_record (~> 0.11.0) + flipper-active_support_cache_store (~> 0.11.0) + fog-aliyun (~> 0.2.0) + fog-aws (~> 1.4) + fog-core (~> 1.44) + fog-google (~> 0.5) + fog-local (~> 0.3) + fog-openstack (~> 0.1) + fog-rackspace (~> 0.1.1) + font-awesome-rails (~> 4.7) + foreman (~> 0.78.0) + fuubar (~> 2.2.0) + gemnasium-gitlab-service (~> 0.2) + gemojione (~> 3.3) + gettext (~> 3.2.2) + gettext_i18n_rails (~> 1.8.0) + gettext_i18n_rails_js (~> 1.2.0) + gitaly-proto (~> 0.88.0) + github-linguist (~> 5.3.3) + gitlab-flowdock-git-hook (~> 1.0.1) + gitlab-markup (~> 1.6.2) + gitlab-styles (~> 2.3) + gitlab_omniauth-ldap (~> 2.0.4) + gollum-lib (~> 4.2) + gollum-rugged_adapter (~> 0.4.4) + gon (~> 6.1.0) + google-api-client (~> 0.13.6) + google-protobuf (= 3.5.1) + gpgme + grape (~> 1.0) + grape-entity (~> 0.6.0) + grape-route-helpers (~> 2.1.0) + grape_logging (~> 1.7) + grpc (~> 1.10.0) + haml_lint (~> 0.26.0) + hamlit (~> 2.6.1) + hashie-forbidden_attributes + health_check (~> 2.6.0) + hipchat (~> 1.5.0) + html-pipeline (~> 2.6.0) + html2text + httparty (~> 0.13.3) + influxdb (~> 0.2) + jira-ruby (~> 1.4) + jquery-atwho-rails (~> 1.3.2) + jquery-rails (~> 4.3.1) + json-schema (~> 2.8.0) + jwt (~> 1.5.6) + kaminari (~> 1.0) + knapsack (~> 1.11.0) + kubeclient (~> 2.2.0) + letter_opener_web (~> 1.3.0) + license_finder (~> 3.1) + licensee (~> 8.7.0) + lograge (~> 0.5) + loofah (~> 2.0.3) + mail_room (~> 0.9.1) + method_source (~> 0.8) + minitest (~> 5.7.0) + mousetrap-rails (~> 1.4.6) + mysql2 (~> 0.4.10) + net-ldap + net-ssh (~> 4.1.0) + nokogiri (~> 1.8.2) + oauth2 (~> 1.4) + octokit (~> 4.6.2) + oj (~> 2.17.4) + omniauth (~> 1.4.2) + omniauth-auth0 (~> 1.4.1) + omniauth-authentiq (~> 0.3.1) + omniauth-azure-oauth2 (~> 0.0.9) + omniauth-cas3 (~> 1.1.4) + omniauth-facebook (~> 4.0.0) + omniauth-github (~> 1.1.1) + omniauth-gitlab (~> 1.0.2) + omniauth-google-oauth2 (~> 0.5.2) + omniauth-kerberos (~> 0.3.0) + omniauth-oauth2-generic (~> 0.2.2) + omniauth-saml (~> 1.7.0) + omniauth-shibboleth (~> 1.2.0) + omniauth-twitter (~> 1.2.0) + omniauth_crowd (~> 2.2.0) + org-ruby (~> 0.9.12) + peek (~> 1.0.1) + peek-gc (~> 0.0.2) + peek-host (~> 1.0.0) + peek-mysql2 (~> 1.1.0) + peek-performance_bar (~> 1.3.0) + peek-pg (~> 1.3.0) + peek-rblineprof (~> 0.2.0) + peek-redis (~> 1.2.0) + peek-sidekiq (~> 1.0.3) + pg (~> 0.18.2) + premailer-rails (~> 1.9.7) + prometheus-client-mmap (~> 0.9.1) + pry-byebug (~> 3.4.1) + pry-rails (~> 0.3.4) + rack-attack (~> 4.4.1) + rack-cors (~> 1.0.0) + rack-oauth2 (~> 1.2.1) + rack-proxy (~> 0.6.0) + rails (= 5.0.6) + rails-deprecated_sanitizer (~> 1.0.3) + rails-i18n (~> 5.1) + rainbow (~> 2.2) + raindrops (~> 0.18) + rblineprof (~> 0.3.6) + rbnacl (~> 4.0) + rbnacl-libsodium + rdoc (~> 4.2) + re2 (~> 1.1.1) + recaptcha (~> 3.0) + redcarpet (~> 3.4) + redis (~> 3.2) + redis-namespace (~> 1.5.2) + redis-rails (~> 5.0.2) + request_store (~> 1.3) + responders (~> 2.0) + rouge (~> 2.0) + rqrcode-rails3 (~> 0.1.7) + rspec-parameterized + rspec-rails (~> 3.6.0) + rspec-retry (~> 0.4.5) + rspec-set (~> 0.1.3) + rspec_profiling (~> 0.0.5) + rubocop (~> 0.52.1) + rubocop-rspec (~> 1.22.1) + ruby-fogbugz (~> 0.2.1) + ruby-prof (~> 0.16.2) + ruby_parser (~> 3.8) + rufus-scheduler (~> 3.4) + rugged (~> 0.26.0) + sanitize (~> 2.0) + sass-rails (~> 5.0.6) + scss_lint (~> 0.56.0) + seed-fu (~> 2.3.7) + select2-rails (~> 3.5.9) + selenium-webdriver (~> 3.5) + sentry-raven (~> 2.5.3) + settingslogic (~> 2.0.9) + sham_rack (~> 1.3.6) + shoulda-matchers (~> 3.1.2) + sidekiq (~> 5.0) + sidekiq-cron (~> 0.6.0) + sidekiq-limit_fetch (~> 3.4) + simple_po_parser (~> 1.1.2) + simplecov (~> 0.14.0) + slack-notifier (~> 1.5.1) + spinach-rails (~> 0.2.1) + spinach-rerun-reporter (~> 0.0.2) + spring (~> 2.0.0) + spring-commands-rspec (~> 1.0.4) + spring-commands-spinach (~> 1.1.0) + sprockets (~> 3.7.0) + sshkey (~> 1.9.0) + stackprof (~> 0.2.10) + state_machines-activerecord (~> 0.4.0) + sys-filesystem (~> 1.1.6) + test-prof (~> 0.2.5) + test_after_commit (~> 1.1) + thin (~> 1.7.0) + timecop (~> 0.8.0) + toml-rb (~> 1.0.0) + truncato (~> 0.7.9) + u2f (~> 0.2.1) + uglifier (~> 2.7.2) + unf (~> 0.1.4) + unicorn (~> 5.1.0) + unicorn-worker-killer (~> 0.4.4) + validates_hostname (~> 1.0.6) + version_sorter (~> 2.1.0) + virtus (~> 1.0.1) + vmstat (~> 2.3.0) + webmock (~> 2.3.2) + webpack-rails (~> 0.9.10) + wikicloth (= 0.8.1) + +BUNDLED WITH + 1.16.1 -- cgit v1.2.1 From 9d14dc820e6df2ed1b7f4de2bba72e0a56b1b96c Mon Sep 17 00:00:00 2001 From: "d.weizhe@gmail.com" Date: Thu, 15 Mar 2018 01:42:32 +0000 Subject: Reques Chinese Traditional proofreader permissions. --- doc/development/i18n/proofreader.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index 960eabd5538..cf62314bc29 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -10,6 +10,7 @@ are very appreciative of the work done by translators and proofreaders! - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve) - Chinese Traditional - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve) + - Weizhe Ding - [GitLab](https://gitlab.com/d.weizhe), [Crowdin](https://crowdin.com/profile/d.weizhe) - Chinese Traditional, Hong Kong - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve) - Dutch -- cgit v1.2.1 From 34cb7057acec0b55b5c09bfc233d880a409fa16b Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Thu, 15 Mar 2018 09:20:51 +0100 Subject: Relax omniauth-saml --- Gemfile | 2 +- Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 6d3999e3243..cc334834988 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem 'omniauth-gitlab', '~> 1.0.2' gem 'omniauth-google-oauth2', '~> 0.5.2' gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos gem 'omniauth-oauth2-generic', '~> 0.2.2' -gem 'omniauth-saml', '~> 1.10.0' +gem 'omniauth-saml', '~> 1.10' gem 'omniauth-shibboleth', '~> 1.2.0' gem 'omniauth-twitter', '~> 1.2.0' gem 'omniauth_crowd', '~> 2.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index dbaf6a93ff8..856610f3279 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1122,7 +1122,7 @@ DEPENDENCIES omniauth-google-oauth2 (~> 0.5.2) omniauth-kerberos (~> 0.3.0) omniauth-oauth2-generic (~> 0.2.2) - omniauth-saml (~> 1.10.0) + omniauth-saml (~> 1.10) omniauth-shibboleth (~> 1.2.0) omniauth-twitter (~> 1.2.0) omniauth_crowd (~> 2.2.0) -- cgit v1.2.1 From 53d7124d26c1d9522b2ce7b5fc08425f84c4b443 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 15 Mar 2018 16:25:30 +0100 Subject: makes caret go to the right side when closing most right issueboard list --- app/views/shared/boards/components/_board.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 2e9ad380012..149bf8da4b9 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -4,7 +4,7 @@ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } %h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' } %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", - ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }", + ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", "aria-hidden": "true" } %span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"", -- cgit v1.2.1 From c2dc539f4c42e3d91adde52443939c5e58fd3802 Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Fri, 16 Mar 2018 09:09:18 +0100 Subject: Update google-api-client --- Gemfile | 2 +- Gemfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 139774bdb5d..aa41e83e866 100644 --- a/Gemfile +++ b/Gemfile @@ -113,7 +113,7 @@ gem 'fog-rackspace', '~> 0.1.1' gem 'fog-aliyun', '~> 0.2.0' # for Google storage -gem 'google-api-client', '~> 0.13.6' +gem 'google-api-client', '~> 0.19.8' # for aws storage gem 'unf', '~> 0.1.4' diff --git a/Gemfile.lock b/Gemfile.lock index 10b683a44ee..3f30ea2b9c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -335,9 +335,9 @@ GEM json multi_json request_store (>= 1.0) - google-api-client (0.13.6) + google-api-client (0.19.8) addressable (~> 2.5, >= 2.5.1) - googleauth (~> 0.5) + googleauth (>= 0.5, < 0.7.0) httpclient (>= 2.8.1, < 3.0) mime-types (~> 3.0) representable (~> 3.0) @@ -414,7 +414,7 @@ GEM httparty (0.13.7) json (~> 1.8) multi_xml (>= 0.5.2) - httpclient (2.8.2) + httpclient (2.8.3) i18n (0.9.5) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -1067,7 +1067,7 @@ DEPENDENCIES gollum-lib (~> 4.2) gollum-rugged_adapter (~> 0.4.4) gon (~> 6.1.0) - google-api-client (~> 0.13.6) + google-api-client (~> 0.19.8) google-protobuf (= 3.5.1) gpgme grape (~> 1.0) -- cgit v1.2.1 From 6ad1b988678b33e7079f1b1558a0e2a74fb8e600 Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Fri, 16 Mar 2018 12:40:04 +0100 Subject: Update doorkeeper-openid_connect and doorkeeper --- Gemfile | 4 ++-- Gemfile.lock | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/Gemfile b/Gemfile index 139774bdb5d..e3ceaf70b5b 100644 --- a/Gemfile +++ b/Gemfile @@ -22,8 +22,8 @@ gem 'faraday', '~> 0.12' # Authentication libraries gem 'devise', '~> 4.2' -gem 'doorkeeper', '~> 4.2.0' -gem 'doorkeeper-openid_connect', '~> 1.2.0' +gem 'doorkeeper', '~> 4.3' +gem 'doorkeeper-openid_connect', '~> 1.3' gem 'omniauth', '~> 1.4.2' gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-azure-oauth2', '~> 0.0.9' diff --git a/Gemfile.lock b/Gemfile.lock index 10b683a44ee..6e251faff65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,6 +47,7 @@ GEM memoizable (~> 0.4.0) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) + aes_key_wrap (1.0.1) akismet (2.0.0) allocations (1.0.5) arel (6.0.4) @@ -86,7 +87,7 @@ GEM coderay (>= 1.0.0) erubis (>= 2.6.6) rack (>= 0.9.0) - bindata (2.4.1) + bindata (2.4.3) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) blankslate (2.1.2.4) @@ -176,10 +177,10 @@ GEM docile (1.1.5) domain_name (0.5.20161021) unf (>= 0.0.5, < 1.0.0) - doorkeeper (4.2.6) + doorkeeper (4.3.1) railties (>= 4.2) - doorkeeper-openid_connect (1.2.0) - doorkeeper (~> 4.0) + doorkeeper-openid_connect (1.3.0) + doorkeeper (~> 4.3) json-jwt (~> 1.6) dropzonejs-rails (0.7.2) rails (> 3.1) @@ -428,10 +429,10 @@ GEM oauth (~> 0.5, >= 0.5.0) jquery-atwho-rails (1.3.2) json (1.8.6) - json-jwt (1.7.2) + json-jwt (1.9.2) activesupport + aes_key_wrap bindata - multi_json (>= 1.3) securecompare url_safe_base64 json-schema (2.8.0) @@ -677,8 +678,8 @@ GEM sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.8) - activesupport (>= 4.2.0.beta, < 5.0) + rails-dom-testing (1.0.9) + activesupport (>= 4.2.0, < 5.0) nokogiri (~> 1.6) rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) @@ -1030,8 +1031,8 @@ DEPENDENCIES devise (~> 4.2) devise-two-factor (~> 3.0.0) diffy (~> 3.1.0) - doorkeeper (~> 4.2.0) - doorkeeper-openid_connect (~> 1.2.0) + doorkeeper (~> 4.3) + doorkeeper-openid_connect (~> 1.3) dropzonejs-rails (~> 0.7.1) email_reply_trimmer (~> 0.1) email_spec (~> 1.6.0) -- cgit v1.2.1 From a0abb904782970de456dae5539ad5de2afef0e05 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 6 Mar 2018 20:06:27 +0100 Subject: Deprecate InternalId concern and rename. --- app/models/concerns/internal_id.rb | 22 ---------------------- app/models/concerns/nonatomic_internal_id.rb | 22 ++++++++++++++++++++++ app/models/deployment.rb | 2 +- app/models/issue.rb | 2 +- app/models/merge_request.rb | 2 +- app/models/milestone.rb | 2 +- spec/models/merge_request_spec.rb | 2 +- 7 files changed, 27 insertions(+), 27 deletions(-) delete mode 100644 app/models/concerns/internal_id.rb create mode 100644 app/models/concerns/nonatomic_internal_id.rb diff --git a/app/models/concerns/internal_id.rb b/app/models/concerns/internal_id.rb deleted file mode 100644 index 01079fb8bd6..00000000000 --- a/app/models/concerns/internal_id.rb +++ /dev/null @@ -1,22 +0,0 @@ -module InternalId - extend ActiveSupport::Concern - - included do - validate :set_iid, on: :create - validates :iid, presence: true, numericality: true - end - - def set_iid - if iid.blank? - parent = project || group - records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend - max_iid = records.maximum(:iid) - - self.iid = max_iid.to_i + 1 - end - end - - def to_param - iid.to_s - end -end diff --git a/app/models/concerns/nonatomic_internal_id.rb b/app/models/concerns/nonatomic_internal_id.rb new file mode 100644 index 00000000000..9d0c9b8512f --- /dev/null +++ b/app/models/concerns/nonatomic_internal_id.rb @@ -0,0 +1,22 @@ +module NonatomicInternalId + extend ActiveSupport::Concern + + included do + validate :set_iid, on: :create + validates :iid, presence: true, numericality: true + end + + def set_iid + if iid.blank? + parent = project || group + records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend + max_iid = records.maximum(:iid) + + self.iid = max_iid.to_i + 1 + end + end + + def to_param + iid.to_s + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 66e61c06765..e18ea8bfea4 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,5 +1,5 @@ class Deployment < ActiveRecord::Base - include InternalId + include NonatomicInternalId belongs_to :project, required: true belongs_to :environment, required: true diff --git a/app/models/issue.rb b/app/models/issue.rb index c81f7e52bb1..cca6224d65c 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -1,7 +1,7 @@ require 'carrierwave/orm/activerecord' class Issue < ActiveRecord::Base - include InternalId + include NonatomicInternalId include Issuable include Noteable include Referable diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 149ef7ec429..7e6d89ec9c7 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,5 +1,5 @@ class MergeRequest < ActiveRecord::Base - include InternalId + include NonatomicInternalId include Issuable include Noteable include Referable diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 77c19380e66..e7d397f40f5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -8,7 +8,7 @@ class Milestone < ActiveRecord::Base Started = MilestoneStruct.new('Started', '#started', -3) include CacheMarkdownField - include InternalId + include NonatomicInternalId include Sortable include Referable include StripAttribute diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 4e783acbd8b..ff5a6f63010 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -17,7 +17,7 @@ describe MergeRequest do describe 'modules' do subject { described_class } - it { is_expected.to include_module(InternalId) } + it { is_expected.to include_module(NonatomicInternalId) } it { is_expected.to include_module(Issuable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } -- cgit v1.2.1 From 754272e392c0da088200a1b56156600973f63267 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 6 Mar 2018 20:09:01 +0100 Subject: Atomic generation of internal ids for issues. --- app/models/concerns/atomic_internal_id.rb | 19 +++++ app/models/internal_id.rb | 84 +++++++++++++++++++++ app/models/issue.rb | 2 +- app/models/project.rb | 2 + .../20180305095250_create_internal_ids_table.rb | 35 +++++++++ db/schema.rb | 9 +++ spec/factories/internal_ids.rb | 6 ++ spec/models/internal_id_spec.rb | 87 ++++++++++++++++++++++ 8 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 app/models/concerns/atomic_internal_id.rb create mode 100644 app/models/internal_id.rb create mode 100644 db/migrate/20180305095250_create_internal_ids_table.rb create mode 100644 spec/factories/internal_ids.rb create mode 100644 spec/models/internal_id_spec.rb diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb new file mode 100644 index 00000000000..3cc9ce7f03f --- /dev/null +++ b/app/models/concerns/atomic_internal_id.rb @@ -0,0 +1,19 @@ +module AtomicInternalId + extend ActiveSupport::Concern + + included do + before_validation(on: :create) do + set_iid + end + + validates :iid, presence: true, numericality: true + end + + def set_iid + self.iid = InternalId.generate_next(self.project, :issues) if iid.blank? + end + + def to_param + iid.to_s + end +end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb new file mode 100644 index 00000000000..24c7cbf988f --- /dev/null +++ b/app/models/internal_id.rb @@ -0,0 +1,84 @@ +# An InternalId is a strictly monotone sequence of integers +# for a given project and usage (e.g. issues). +# +# For possible usages, see InternalId#usage enum. +class InternalId < ActiveRecord::Base + belongs_to :project + + enum usage: { issues: 0 } + + validates :usage, presence: true + validates :project_id, presence: true + + # Increments #last_value and saves the record + # + # The operation locks the record and gathers + # a `ROW SHARE` lock (in PostgreSQL). As such, + # the increment is atomic and safe to be called + # concurrently. + def increment_and_save! + lock! + self.last_value = (last_value || 0) + 1 + save! + last_value + end + + before_create :calculate_last_value! + + # Calculate #last_value by counting the number of + # existing records for this usage. + def calculate_last_value! + return if last_value + + parent = project # ??|| group + self.last_value = parent.send(usage.to_sym).maximum(:iid) || 0 # rubocop:disable GitlabSecurity/PublicSend + end + + class << self + # Generate next internal id for a given project and usage. + # + # For currently supported usages, see #usage enum. + # + # The method implements a locking scheme that has the following properties: + # 1) Generated sequence of internal ids is unique per (project, usage) + # 2) The method is thread-safe and may be used in concurrent threads/processes. + # 3) The generated sequence is gapless. + # 4) In the absence of a record in the internal_ids table, one will be created + # and last_value will be calculated on the fly. + def generate_next(project, usage) + raise 'project not set - this is required' unless project + + project.transaction do + # Create a record in internal_ids if one does not yet exist + id = (lookup(project, usage) || create_record(project, usage)) + + # This will lock the InternalId record with ROW SHARE + # and increment #last_value + id.increment_and_save! + end + end + + private + + # Retrieve InternalId record for (project, usage) combination, if it exists + def lookup(project, usage) + project.internal_ids.find_by(usage: usages[usage.to_s]) + end + + # Create InternalId record for (project, usage) combination, if it doesn't exist + # + # We blindly insert without any synchronization. If another process + # was faster in doing this, we'll realize once we hit the unique key constraint + # violation. We can safely roll-back the nested transaction and perform + # a lookup instead to retrieve the record. + def create_record(project, usage) + begin + project.transaction(requires_new: true) do + create!(project: project, usage: usages[usage.to_s]) + end + rescue ActiveRecord::RecordNotUnique + lookup(project, usage) + end + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index cca6224d65c..cf547106122 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -1,7 +1,7 @@ require 'carrierwave/orm/activerecord' class Issue < ActiveRecord::Base - include NonatomicInternalId + include AtomicInternalId include Issuable include Noteable include Referable diff --git a/app/models/project.rb b/app/models/project.rb index a291ad7eed5..93b0da5cce1 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -188,6 +188,8 @@ class Project < ActiveRecord::Base has_many :todos has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :internal_ids + has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true has_one :project_feature, inverse_of: :project has_one :statistics, class_name: 'ProjectStatistics' diff --git a/db/migrate/20180305095250_create_internal_ids_table.rb b/db/migrate/20180305095250_create_internal_ids_table.rb new file mode 100644 index 00000000000..19c1904bb43 --- /dev/null +++ b/db/migrate/20180305095250_create_internal_ids_table.rb @@ -0,0 +1,35 @@ +class CreateInternalIdsTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :internal_ids do |t| + t.references :project + t.integer :usage, null: false + t.integer :last_value, null: false + end + + unless index_exists?(:internal_ids, [:usage, :project_id]) + add_index :internal_ids, [:usage, :project_id], unique: true + end + + unless foreign_key_exists?(:internal_ids, :project_id) + add_concurrent_foreign_key :internal_ids, :projects, column: :project_id, on_delete: :cascade + end + end + + def down + drop_table :internal_ids + end + + private + + def foreign_key_exists?(table, column) + foreign_keys(table).any? do |key| + key.options[:column] == column.to_s + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ab4370e2754..3785bf14d5c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -866,6 +866,14 @@ ActiveRecord::Schema.define(version: 20180309160427) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "internal_ids", force: :cascade do |t| + t.integer "project_id" + t.integer "usage", null: false + t.integer "last_value", null: false + end + + add_index "internal_ids", ["usage", "project_id"], name: "index_internal_ids_on_usage_and_project_id", unique: true, using: :btree + create_table "issue_assignees", id: false, force: :cascade do |t| t.integer "user_id", null: false t.integer "issue_id", null: false @@ -2058,6 +2066,7 @@ ActiveRecord::Schema.define(version: 20180309160427) do add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "internal_ids", "projects", name: "fk_f7d46b66c6", on_delete: :cascade add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade diff --git a/spec/factories/internal_ids.rb b/spec/factories/internal_ids.rb new file mode 100644 index 00000000000..b4c14d22a29 --- /dev/null +++ b/spec/factories/internal_ids.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :internal_id do + project + usage { InternalId.usages.keys.first } + end +end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb new file mode 100644 index 00000000000..b953b6a2df8 --- /dev/null +++ b/spec/models/internal_id_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +describe InternalId do + let(:project) { create(:project) } + let(:usage) { :issues } + + context 'validations' do + it { is_expected.to validate_presence_of(:usage) } + it { is_expected.to validate_presence_of(:project_id) } + end + + describe '.generate_next' do + context 'in the absence of a record' do + subject { described_class.generate_next(project, usage) } + + it 'creates a record if not yet present' do + expect { subject }.to change { described_class.count }.from(0).to(1) + end + + it 'stores record attributes' do + subject + + described_class.first.tap do |record| + expect(record.project).to eq(project) + expect(record.usage).to eq(usage.to_s) # TODO + end + end + + context 'with existing issues' do + before do + rand(10).times { create(:issue, project: project) } + end + + it 'calculates last_value values automatically' do + expect(subject).to eq(project.issues.size + 1) + end + end + end + + it 'generates a strictly monotone, gapless sequence' do + seq = (0..rand(1000)).map do + described_class.generate_next(project, usage) + end + normalized = seq.map { |i| i - seq.min } + expect(normalized).to eq((0..seq.size - 1).to_a) + end + end + + describe '#increment_and_save!' do + let(:id) { create(:internal_id) } + subject { id.increment_and_save! } + + it 'returns incremented iid' do + value = id.last_value + expect(subject).to eq(value + 1) + end + + it 'saves the record' do + subject + expect(id.changed?).to be_falsey + end + + context 'with last_value=nil' do + let(:id) { build(:internal_id, last_value: nil) } + + it 'returns 1' do + expect(subject).to eq(1) + end + end + end + + describe '#calculate_last_value! (for issues)' do + subject do + build(:internal_id, project: project, usage: :issues) + end + + context 'with existing issues' do + before do + rand(10).times { create(:issue, project: project) } + end + + it 'counts related issues and saves' do + expect { subject.calculate_last_value! }.to change { subject.last_value }.from(nil).to(project.issues.size) + end + end + end +end -- cgit v1.2.1 From 0360b0928aada1db7635e6c6bc40f0f571b72c30 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Mon, 12 Mar 2018 15:38:56 +0100 Subject: More flexible way of internal id generation. --- app/models/concerns/atomic_internal_id.rb | 20 +++++++++------- app/models/internal_id.rb | 40 +++++++++++++++---------------- app/models/issue.rb | 2 ++ spec/factories/internal_ids.rb | 3 ++- spec/models/internal_id_spec.rb | 26 ++++++-------------- 5 files changed, 42 insertions(+), 49 deletions(-) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 3cc9ce7f03f..eef5c0bfcd1 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -2,15 +2,19 @@ module AtomicInternalId extend ActiveSupport::Concern included do - before_validation(on: :create) do - set_iid - end - - validates :iid, presence: true, numericality: true - end + class << self + def has_internal_id(on, scope:, usage: nil, init: nil) + before_validation(on: :create) do + if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend + usage = (usage || self.class.name.tableize).to_sym + new_iid = InternalId.generate_next(self, scope, usage, init) + self.public_send("#{on}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend + end + end - def set_iid - self.iid = InternalId.generate_next(self.project, :issues) if iid.blank? + validates on, presence: true, numericality: true + end + end end def to_param diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 24c7cbf988f..58e71b623d0 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -23,17 +23,6 @@ class InternalId < ActiveRecord::Base last_value end - before_create :calculate_last_value! - - # Calculate #last_value by counting the number of - # existing records for this usage. - def calculate_last_value! - return if last_value - - parent = project # ??|| group - self.last_value = parent.send(usage.to_sym).maximum(:iid) || 0 # rubocop:disable GitlabSecurity/PublicSend - end - class << self # Generate next internal id for a given project and usage. # @@ -45,12 +34,21 @@ class InternalId < ActiveRecord::Base # 3) The generated sequence is gapless. # 4) In the absence of a record in the internal_ids table, one will be created # and last_value will be calculated on the fly. - def generate_next(project, usage) - raise 'project not set - this is required' unless project + def generate_next(subject, scope, usage, init) + scope = [scope].flatten.compact + raise 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? + raise "usage #{usage} is unknown. Supported values are InternalId.usages = #{InternalId.usages.keys.to_s}" unless InternalId.usages.include?(usage.to_sym) + + init ||= ->(s) { 0 } + + scope_attrs = scope.inject({}) do |h, e| + h[e] = subject.public_send(e) + h + end - project.transaction do + transaction do # Create a record in internal_ids if one does not yet exist - id = (lookup(project, usage) || create_record(project, usage)) + id = (lookup(scope_attrs, usage) || create_record(scope_attrs, usage, init, subject)) # This will lock the InternalId record with ROW SHARE # and increment #last_value @@ -61,8 +59,8 @@ class InternalId < ActiveRecord::Base private # Retrieve InternalId record for (project, usage) combination, if it exists - def lookup(project, usage) - project.internal_ids.find_by(usage: usages[usage.to_s]) + def lookup(scope_attrs, usage) + InternalId.find_by(usage: usages[usage.to_s], **scope_attrs) end # Create InternalId record for (project, usage) combination, if it doesn't exist @@ -71,13 +69,13 @@ class InternalId < ActiveRecord::Base # was faster in doing this, we'll realize once we hit the unique key constraint # violation. We can safely roll-back the nested transaction and perform # a lookup instead to retrieve the record. - def create_record(project, usage) + def create_record(scope_attrs, usage, init, subject) begin - project.transaction(requires_new: true) do - create!(project: project, usage: usages[usage.to_s]) + transaction(requires_new: true) do + create!(usage: usages[usage.to_s], **scope_attrs, last_value: init.call(subject) || 0) end rescue ActiveRecord::RecordNotUnique - lookup(project, usage) + lookup(scope_attrs, usage) end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index cf547106122..509e214c601 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -24,6 +24,8 @@ class Issue < ActiveRecord::Base belongs_to :project belongs_to :moved_to, class_name: 'Issue' + has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) } + has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :merge_requests_closing_issues, diff --git a/spec/factories/internal_ids.rb b/spec/factories/internal_ids.rb index b4c14d22a29..fbde07a391a 100644 --- a/spec/factories/internal_ids.rb +++ b/spec/factories/internal_ids.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :internal_id do project - usage { InternalId.usages.keys.first } + usage :issues + last_value { project.issues.maximum(:iid) || 0 } end end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index b953b6a2df8..5971d8f47a6 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' describe InternalId do let(:project) { create(:project) } let(:usage) { :issues } + let(:issue) { build(:issue, project: project) } + let(:scope) { :project } + let(:init) { ->(s) { project.issues.size } } context 'validations' do it { is_expected.to validate_presence_of(:usage) } @@ -11,7 +14,7 @@ describe InternalId do describe '.generate_next' do context 'in the absence of a record' do - subject { described_class.generate_next(project, usage) } + subject { described_class.generate_next(issue, scope, usage, init) } it 'creates a record if not yet present' do expect { subject }.to change { described_class.count }.from(0).to(1) @@ -22,13 +25,14 @@ describe InternalId do described_class.first.tap do |record| expect(record.project).to eq(project) - expect(record.usage).to eq(usage.to_s) # TODO + expect(record.usage).to eq(usage.to_s) end end context 'with existing issues' do before do rand(10).times { create(:issue, project: project) } + InternalId.delete_all end it 'calculates last_value values automatically' do @@ -39,7 +43,7 @@ describe InternalId do it 'generates a strictly monotone, gapless sequence' do seq = (0..rand(1000)).map do - described_class.generate_next(project, usage) + described_class.generate_next(issue, scope, usage, init) end normalized = seq.map { |i| i - seq.min } expect(normalized).to eq((0..seq.size - 1).to_a) @@ -68,20 +72,4 @@ describe InternalId do end end end - - describe '#calculate_last_value! (for issues)' do - subject do - build(:internal_id, project: project, usage: :issues) - end - - context 'with existing issues' do - before do - rand(10).times { create(:issue, project: project) } - end - - it 'counts related issues and saves' do - expect { subject.calculate_last_value! }.to change { subject.last_value }.from(nil).to(project.issues.size) - end - end - end end -- cgit v1.2.1 From 3fa2eb4e10885c9b3a01f0f9f244c9fbb26cf6a1 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Mon, 12 Mar 2018 16:51:38 +0100 Subject: Refactor, extract class and improve comments. --- app/models/concerns/atomic_internal_id.rb | 32 ++++++++++- app/models/internal_id.rb | 93 ++++++++++++++++++++----------- app/models/issue.rb | 2 +- spec/models/concerns/issuable_spec.rb | 2 +- spec/models/internal_id_spec.rb | 9 ++- 5 files changed, 95 insertions(+), 43 deletions(-) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index eef5c0bfcd1..6a0f29806c4 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -1,13 +1,41 @@ +# Include atomic internal id generation scheme for a model +# +# This allows to atomically generate internal ids that are +# unique within a given scope. +# +# For example, let's generate internal ids for Issue per Project: +# ``` +# class Issue < ActiveRecord::Base +# has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) } +# end +# ``` +# +# This generates unique internal ids per project for newly created issues. +# The generated internal id is saved in the `iid` attribute of `Issue`. +# +# This concern uses InternalId records to facilitate atomicity. +# In the absence of a record for the given scope, one will be created automatically. +# In this situation, the `init` block is called to calculate the initial value. +# In the example above, we calculate the maximum `iid` of all issues +# within the given project. +# +# Note that a model may have more than one internal id associated with possibly +# different scopes. module AtomicInternalId extend ActiveSupport::Concern included do class << self - def has_internal_id(on, scope:, usage: nil, init: nil) + def has_internal_id(on, scope:, usage: nil, init: nil) # rubocop:disable Naming/PredicateName before_validation(on: :create) do if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend + + scope_attrs = [scope].flatten.compact.each_with_object({}) do |e, h| + h[e] = self.public_send(e) # rubocop:disable GitlabSecurity/PublicSend + end + usage = (usage || self.class.name.tableize).to_sym - new_iid = InternalId.generate_next(self, scope, usage, init) + new_iid = InternalId.generate_next(self, scope_attrs, usage, init) self.public_send("#{on}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 58e71b623d0..4673dffa842 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -1,21 +1,26 @@ # An InternalId is a strictly monotone sequence of integers -# for a given project and usage (e.g. issues). +# generated for a given scope and usage. # -# For possible usages, see InternalId#usage enum. +# For example, issues use their project to scope internal ids: +# In that sense, scope is "project" and usage is "issues". +# Generated internal ids for an issue are unique per project. +# +# See InternalId#usage enum for available usages. +# +# In order to leverage InternalId for other usages, the idea is to +# * Add `usage` value to enum +# * (Optionally) add columns to `internal_ids` if needed for scope. class InternalId < ActiveRecord::Base belongs_to :project enum usage: { issues: 0 } validates :usage, presence: true - validates :project_id, presence: true # Increments #last_value and saves the record # - # The operation locks the record and gathers - # a `ROW SHARE` lock (in PostgreSQL). As such, - # the increment is atomic and safe to be called - # concurrently. + # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL). + # As such, the increment is atomic and safe to be called concurrently. def increment_and_save! lock! self.last_value = (last_value || 0) + 1 @@ -24,59 +29,79 @@ class InternalId < ActiveRecord::Base end class << self - # Generate next internal id for a given project and usage. + def generate_next(subject, scope, usage, init) + InternalIdGenerator.new(subject, scope, usage, init).generate + end + end + + class InternalIdGenerator + # Generate next internal id for a given scope and usage. # # For currently supported usages, see #usage enum. # # The method implements a locking scheme that has the following properties: - # 1) Generated sequence of internal ids is unique per (project, usage) + # 1) Generated sequence of internal ids is unique per (scope and usage) # 2) The method is thread-safe and may be used in concurrent threads/processes. # 3) The generated sequence is gapless. # 4) In the absence of a record in the internal_ids table, one will be created # and last_value will be calculated on the fly. - def generate_next(subject, scope, usage, init) - scope = [scope].flatten.compact - raise 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? - raise "usage #{usage} is unknown. Supported values are InternalId.usages = #{InternalId.usages.keys.to_s}" unless InternalId.usages.include?(usage.to_sym) + # + # subject: The instance we're generating an internal id for. Gets passed to init if called. + # scope: Attributes that define the scope for id generation. + # usage: Symbol to define the usage of the internal id, see InternalId.usages + # init: Block that gets called to initialize InternalId record if not yet present (optional) + attr_reader :subject, :scope, :init, :scope_attrs, :usage + def initialize(subject, scope, usage, init) + @subject = subject + @scope = scope + @init = init || ->(s) { 0 } + @usage = usage - init ||= ->(s) { 0 } + raise 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? - scope_attrs = scope.inject({}) do |h, e| - h[e] = subject.public_send(e) - h + unless InternalId.usages.keys.include?(usage.to_s) + raise "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages" end + end - transaction do + # Generates next internal id and returns it + def generate + subject.transaction do # Create a record in internal_ids if one does not yet exist - id = (lookup(scope_attrs, usage) || create_record(scope_attrs, usage, init, subject)) - - # This will lock the InternalId record with ROW SHARE - # and increment #last_value - id.increment_and_save! + # and increment it's last value + # + # Note this will acquire a ROW SHARE lock on the InternalId record + (lookup || create_record).increment_and_save! end end private # Retrieve InternalId record for (project, usage) combination, if it exists - def lookup(scope_attrs, usage) - InternalId.find_by(usage: usages[usage.to_s], **scope_attrs) + def lookup + InternalId.find_by(**scope, usage: usage_value) + end + + def usage_value + @usage_value ||= InternalId.usages[usage.to_s] end - # Create InternalId record for (project, usage) combination, if it doesn't exist + # Create InternalId record for (scope, usage) combination, if it doesn't exist # - # We blindly insert without any synchronization. If another process + # We blindly insert without synchronization. If another process # was faster in doing this, we'll realize once we hit the unique key constraint # violation. We can safely roll-back the nested transaction and perform # a lookup instead to retrieve the record. - def create_record(scope_attrs, usage, init, subject) - begin - transaction(requires_new: true) do - create!(usage: usages[usage.to_s], **scope_attrs, last_value: init.call(subject) || 0) - end - rescue ActiveRecord::RecordNotUnique - lookup(scope_attrs, usage) + def create_record + subject.transaction(requires_new: true) do + InternalId.create!( + **scope, + usage: usage_value, + last_value: init.call(subject) || 0 + ) end + rescue ActiveRecord::RecordNotUnique + lookup end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 509e214c601..7bfc45c1f43 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -24,7 +24,7 @@ class Issue < ActiveRecord::Base belongs_to :project belongs_to :moved_to, class_name: 'Issue' - has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) } + has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) } has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 4b217df2e8f..f8874d14e3f 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -34,7 +34,7 @@ describe Issuable do subject { build(:issue) } before do - allow(subject).to receive(:set_iid).and_return(false) + allow(InternalId).to receive(:generate_next).and_return(nil) end it { is_expected.to validate_presence_of(:project) } diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index 5971d8f47a6..6d5a12c9d06 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -4,12 +4,11 @@ describe InternalId do let(:project) { create(:project) } let(:usage) { :issues } let(:issue) { build(:issue, project: project) } - let(:scope) { :project } - let(:init) { ->(s) { project.issues.size } } + let(:scope) { { project: project } } + let(:init) { ->(s) { s.project.issues.size } } context 'validations' do it { is_expected.to validate_presence_of(:usage) } - it { is_expected.to validate_presence_of(:project_id) } end describe '.generate_next' do @@ -31,8 +30,8 @@ describe InternalId do context 'with existing issues' do before do - rand(10).times { create(:issue, project: project) } - InternalId.delete_all + rand(1..10).times { create(:issue, project: project) } + described_class.delete_all end it 'calculates last_value values automatically' do -- cgit v1.2.1 From b1c7ee37ea85dfe4a4fa99342dc09d8ea436769e Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Tue, 6 Jun 2017 16:37:41 -0300 Subject: Add bigserial as a native database type for MySQL adapter --- config/initializers/ar_native_database_types.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 config/initializers/ar_native_database_types.rb diff --git a/config/initializers/ar_native_database_types.rb b/config/initializers/ar_native_database_types.rb new file mode 100644 index 00000000000..d7b4b348957 --- /dev/null +++ b/config/initializers/ar_native_database_types.rb @@ -0,0 +1,11 @@ +require 'active_record/connection_adapters/abstract_mysql_adapter' + +module ActiveRecord + module ConnectionAdapters + class AbstractMysqlAdapter + NATIVE_DATABASE_TYPES.merge!( + bigserial: { name: 'bigint(20) auto_increment PRIMARY KEY' } + ) + end + end +end -- cgit v1.2.1 From 3a8207a96120dbb23e9feddd372e720ef0884378 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Mon, 12 Mar 2018 18:12:38 +0100 Subject: Use bigserial for internal_ids table. --- db/migrate/20180305095250_create_internal_ids_table.rb | 2 +- db/schema.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20180305095250_create_internal_ids_table.rb b/db/migrate/20180305095250_create_internal_ids_table.rb index 19c1904bb43..d2e79a12c0a 100644 --- a/db/migrate/20180305095250_create_internal_ids_table.rb +++ b/db/migrate/20180305095250_create_internal_ids_table.rb @@ -6,7 +6,7 @@ class CreateInternalIdsTable < ActiveRecord::Migration disable_ddl_transaction! def up - create_table :internal_ids do |t| + create_table :internal_ids, id: :bigserial do |t| t.references :project t.integer :usage, null: false t.integer :last_value, null: false diff --git a/db/schema.rb b/db/schema.rb index 3785bf14d5c..aab8ad4753e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -866,7 +866,7 @@ ActiveRecord::Schema.define(version: 20180309160427) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree - create_table "internal_ids", force: :cascade do |t| + create_table "internal_ids", id: :bigserial, force: :cascade do |t| t.integer "project_id" t.integer "usage", null: false t.integer "last_value", null: false -- cgit v1.2.1 From d4bb363f7c2747d15e496aee98bc7cc3fde77a77 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Mon, 12 Mar 2018 18:16:20 +0100 Subject: Add changelog. Closes #31114. --- changelogs/unreleased/31114-internal-ids-are-not-atomic.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/31114-internal-ids-are-not-atomic.yml diff --git a/changelogs/unreleased/31114-internal-ids-are-not-atomic.yml b/changelogs/unreleased/31114-internal-ids-are-not-atomic.yml new file mode 100644 index 00000000000..bc1955bc66f --- /dev/null +++ b/changelogs/unreleased/31114-internal-ids-are-not-atomic.yml @@ -0,0 +1,5 @@ +--- +title: Atomic generation of internal ids for issues. +merge_request: 17580 +author: +type: other -- cgit v1.2.1 From d374d0be1af0e6cef4a150425cd73189f9960f54 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 13 Mar 2018 16:53:55 +0100 Subject: Backwards-compat for migration specs. The specs are based on a schema version that doesn't know about `internal_ids` table. However, the actual code being execute relies on it. --- app/models/internal_id.rb | 20 ++++++++++++++++++-- spec/models/internal_id_spec.rb | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 4673dffa842..6419cdc5b67 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -17,6 +17,8 @@ class InternalId < ActiveRecord::Base validates :usage, presence: true + REQUIRED_SCHEMA_VERSION = 20180305095250 + # Increments #last_value and saves the record # # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL). @@ -30,8 +32,22 @@ class InternalId < ActiveRecord::Base class << self def generate_next(subject, scope, usage, init) + # Shortcut if `internal_ids` table is not available (yet) + # This can be the case in other (unrelated) migration specs + return (init.call(subject) || 0) + 1 unless available? + InternalIdGenerator.new(subject, scope, usage, init).generate end + + def available? + @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization + end + + # Flushes cached information about schema + def reset_column_information + @available_flag = nil + super + end end class InternalIdGenerator @@ -49,12 +65,12 @@ class InternalId < ActiveRecord::Base # subject: The instance we're generating an internal id for. Gets passed to init if called. # scope: Attributes that define the scope for id generation. # usage: Symbol to define the usage of the internal id, see InternalId.usages - # init: Block that gets called to initialize InternalId record if not yet present (optional) + # init: Block that gets called to initialize InternalId record if not present attr_reader :subject, :scope, :init, :scope_attrs, :usage def initialize(subject, scope, usage, init) @subject = subject @scope = scope - @init = init || ->(s) { 0 } + @init = init @usage = usage raise 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index 6d5a12c9d06..ef6db2daa95 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -12,9 +12,9 @@ describe InternalId do end describe '.generate_next' do - context 'in the absence of a record' do - subject { described_class.generate_next(issue, scope, usage, init) } + subject { described_class.generate_next(issue, scope, usage, init) } + context 'in the absence of a record' do it 'creates a record if not yet present' do expect { subject }.to change { described_class.count }.from(0).to(1) end @@ -47,6 +47,21 @@ describe InternalId do normalized = seq.map { |i| i - seq.min } expect(normalized).to eq((0..seq.size - 1).to_a) end + + context 'with an insufficient schema version' do + before do + described_class.reset_column_information + expect(ActiveRecord::Migrator).to receive(:current_version).and_return(InternalId::REQUIRED_SCHEMA_VERSION - 1) + end + + let(:init) { double('block') } + + it 'calculates next internal ids on the fly' do + val = rand(1..100) + expect(init).to receive(:call).with(issue).and_return(val) + expect(subject).to eq(val + 1) + end + end end describe '#increment_and_save!' do -- cgit v1.2.1 From 6568b4a98e25d88a99b7a499bc7965ae47bf4b19 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 13 Mar 2018 19:43:02 +0100 Subject: Add shared specs for AtomicInternalId concern. --- app/models/concerns/atomic_internal_id.rb | 2 +- spec/models/issue_spec.rb | 8 ++++- .../models/atomic_internal_id_spec.rb | 38 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 spec/support/shared_examples/models/atomic_internal_id_spec.rb diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 6a0f29806c4..bba71eee8bd 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -26,7 +26,7 @@ module AtomicInternalId included do class << self - def has_internal_id(on, scope:, usage: nil, init: nil) # rubocop:disable Naming/PredicateName + def has_internal_id(on, scope:, usage: nil, init:) # rubocop:disable Naming/PredicateName before_validation(on: :create) do if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index feed7968f09..11154291368 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -9,11 +9,17 @@ describe Issue do describe 'modules' do subject { described_class } - it { is_expected.to include_module(InternalId) } it { is_expected.to include_module(Issuable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } it { is_expected.to include_module(Taskable) } + + it_behaves_like 'AtomicInternalId' do + let(:internal_id_attribute) { :iid } + let(:instance) { build(:issue) } + let(:scope_attrs) { { project: instance.project } } + let(:usage) { :issues } + end end subject { create(:issue) } diff --git a/spec/support/shared_examples/models/atomic_internal_id_spec.rb b/spec/support/shared_examples/models/atomic_internal_id_spec.rb new file mode 100644 index 00000000000..671aa314bd6 --- /dev/null +++ b/spec/support/shared_examples/models/atomic_internal_id_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +shared_examples_for 'AtomicInternalId' do + describe '.has_internal_id' do + describe 'Module inclusion' do + subject { described_class } + + it { is_expected.to include_module(AtomicInternalId) } + end + + describe 'Validation' do + subject { instance } + + before do + allow(InternalId).to receive(:generate_next).and_return(nil) + end + + it { is_expected.to validate_presence_of(internal_id_attribute) } + it { is_expected.to validate_numericality_of(internal_id_attribute) } + end + + describe 'internal id generation' do + subject { instance.save! } + + it 'calls InternalId.generate_next and sets internal id attribute' do + iid = rand(1..1000) + expect(InternalId).to receive(:generate_next).with(instance, scope_attrs, usage, any_args).and_return(iid) + subject + expect(instance.public_send(internal_id_attribute)).to eq(iid) # rubocop:disable GitlabSecurity/PublicSend + end + + it 'does not overwrite an existing internal id' do + instance.public_send("#{internal_id_attribute}=", 4711) # rubocop:disable GitlabSecurity/PublicSend + expect { subject }.not_to change { instance.public_send(internal_id_attribute) } # rubocop:disable GitlabSecurity/PublicSend + end + end + end +end -- cgit v1.2.1 From d9a953c847339c3d906be4540bdc2a3c44f13fca Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 13 Mar 2018 22:17:29 +0100 Subject: Add new model to import/export configuration. --- spec/lib/gitlab/import_export/all_models.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index bece82e531a..a204a8f1ffe 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -279,6 +279,7 @@ project: - lfs_file_locks - project_badges - source_of_merge_requests +- internal_ids award_emoji: - awardable - user -- cgit v1.2.1 From 539bdf73be4a84b1f542801a28f016808d604fe5 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Wed, 14 Mar 2018 13:42:03 +0100 Subject: Address review comments. --- app/models/concerns/atomic_internal_id.rb | 27 ++++++++++++--------------- app/models/internal_id.rb | 5 +++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index bba71eee8bd..6f2d60a7ee9 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -24,24 +24,21 @@ module AtomicInternalId extend ActiveSupport::Concern - included do - class << self - def has_internal_id(on, scope:, usage: nil, init:) # rubocop:disable Naming/PredicateName - before_validation(on: :create) do - if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend - - scope_attrs = [scope].flatten.compact.each_with_object({}) do |e, h| - h[e] = self.public_send(e) # rubocop:disable GitlabSecurity/PublicSend - end - - usage = (usage || self.class.name.tableize).to_sym - new_iid = InternalId.generate_next(self, scope_attrs, usage, init) - self.public_send("#{on}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend + module ClassMethods + def has_internal_id(on, scope:, usage: nil, init:) # rubocop:disable Naming/PredicateName + before_validation(on: :create) do + if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend + scope_attrs = [scope].flatten.compact.each_with_object({}) do |e, h| + h[e] = self.public_send(e) # rubocop:disable GitlabSecurity/PublicSend end - end - validates on, presence: true, numericality: true + usage = (usage || self.class.table_name).to_sym + new_iid = InternalId.generate_next(self, scope_attrs, usage, init) + self.public_send("#{on}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend + end end + + validates on, presence: true, numericality: true end end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 6419cdc5b67..f3630ed1ac5 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -67,16 +67,17 @@ class InternalId < ActiveRecord::Base # usage: Symbol to define the usage of the internal id, see InternalId.usages # init: Block that gets called to initialize InternalId record if not present attr_reader :subject, :scope, :init, :scope_attrs, :usage + def initialize(subject, scope, usage, init) @subject = subject @scope = scope @init = init @usage = usage - raise 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? + raise ArgumentError, 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? unless InternalId.usages.keys.include?(usage.to_s) - raise "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages" + raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages" end end -- cgit v1.2.1 From 4c1e6fd0e83ceb550ec07448d6a37e76cc25e4fa Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Wed, 14 Mar 2018 13:42:24 +0100 Subject: Simplify migration and add NOT NULL to project_id. --- .../20180305095250_create_internal_ids_table.rb | 20 ++------------------ db/schema.rb | 4 ++-- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/db/migrate/20180305095250_create_internal_ids_table.rb b/db/migrate/20180305095250_create_internal_ids_table.rb index d2e79a12c0a..e972432fb98 100644 --- a/db/migrate/20180305095250_create_internal_ids_table.rb +++ b/db/migrate/20180305095250_create_internal_ids_table.rb @@ -3,33 +3,17 @@ class CreateInternalIdsTable < ActiveRecord::Migration DOWNTIME = false - disable_ddl_transaction! - def up create_table :internal_ids, id: :bigserial do |t| - t.references :project + t.references :project, null: false, foreign_key: { on_delete: :cascade } t.integer :usage, null: false t.integer :last_value, null: false - end - - unless index_exists?(:internal_ids, [:usage, :project_id]) - add_index :internal_ids, [:usage, :project_id], unique: true - end - unless foreign_key_exists?(:internal_ids, :project_id) - add_concurrent_foreign_key :internal_ids, :projects, column: :project_id, on_delete: :cascade + t.index [:usage, :project_id], unique: true end end def down drop_table :internal_ids end - - private - - def foreign_key_exists?(table, column) - foreign_keys(table).any? do |key| - key.options[:column] == column.to_s - end - end end diff --git a/db/schema.rb b/db/schema.rb index aab8ad4753e..3ff1a8754e2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -867,7 +867,7 @@ ActiveRecord::Schema.define(version: 20180309160427) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree create_table "internal_ids", id: :bigserial, force: :cascade do |t| - t.integer "project_id" + t.integer "project_id", null: false t.integer "usage", null: false t.integer "last_value", null: false end @@ -2066,7 +2066,7 @@ ActiveRecord::Schema.define(version: 20180309160427) do add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade - add_foreign_key "internal_ids", "projects", name: "fk_f7d46b66c6", on_delete: :cascade + add_foreign_key "internal_ids", "projects", on_delete: :cascade add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade -- cgit v1.2.1 From bc3fc8ec3eec74876a0e2125248c27cde153e32b Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Wed, 14 Mar 2018 14:36:07 +0100 Subject: Only support single scope argument. We can extend this later, but for now we don't have the use case. --- app/models/concerns/atomic_internal_id.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 6f2d60a7ee9..343edc237c9 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -28,11 +28,9 @@ module AtomicInternalId def has_internal_id(on, scope:, usage: nil, init:) # rubocop:disable Naming/PredicateName before_validation(on: :create) do if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend - scope_attrs = [scope].flatten.compact.each_with_object({}) do |e, h| - h[e] = self.public_send(e) # rubocop:disable GitlabSecurity/PublicSend - end - + scope_attrs = { scope => self.public_send(scope) } # rubocop:disable GitlabSecurity/PublicSend usage = (usage || self.class.table_name).to_sym + new_iid = InternalId.generate_next(self, scope_attrs, usage, init) self.public_send("#{on}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend end -- cgit v1.2.1 From fb6d6fce5a4d0fd833dc1cd231dd284a6c89471a Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Fri, 16 Mar 2018 13:34:08 +0100 Subject: Address review comments. --- app/models/concerns/atomic_internal_id.rb | 12 ++++++------ app/models/internal_id.rb | 7 ++++--- config/initializers/ar_native_database_types.rb | 2 +- db/migrate/20180305095250_create_internal_ids_table.rb | 6 +----- spec/models/internal_id_spec.rb | 6 +++++- .../shared_examples/models/atomic_internal_id_spec.rb | 8 +++++--- 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 343edc237c9..6895c7d7e95 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -1,6 +1,6 @@ # Include atomic internal id generation scheme for a model # -# This allows to atomically generate internal ids that are +# This allows us to atomically generate internal ids that are # unique within a given scope. # # For example, let's generate internal ids for Issue per Project: @@ -25,18 +25,18 @@ module AtomicInternalId extend ActiveSupport::Concern module ClassMethods - def has_internal_id(on, scope:, usage: nil, init:) # rubocop:disable Naming/PredicateName + def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName before_validation(on: :create) do - if self.public_send(on).blank? # rubocop:disable GitlabSecurity/PublicSend + if self.public_send(column).blank? # rubocop:disable GitlabSecurity/PublicSend scope_attrs = { scope => self.public_send(scope) } # rubocop:disable GitlabSecurity/PublicSend - usage = (usage || self.class.table_name).to_sym + usage = self.class.table_name.to_sym new_iid = InternalId.generate_next(self, scope_attrs, usage, init) - self.public_send("#{on}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend + self.public_send("#{column}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend end end - validates on, presence: true, numericality: true + validates column, presence: true, numericality: true end end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index f3630ed1ac5..cbec735c2dd 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -66,6 +66,7 @@ class InternalId < ActiveRecord::Base # scope: Attributes that define the scope for id generation. # usage: Symbol to define the usage of the internal id, see InternalId.usages # init: Block that gets called to initialize InternalId record if not present + # Make sure to not throw exceptions in the absence of records (if this is expected). attr_reader :subject, :scope, :init, :scope_attrs, :usage def initialize(subject, scope, usage, init) @@ -74,9 +75,9 @@ class InternalId < ActiveRecord::Base @init = init @usage = usage - raise ArgumentError, 'scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? + raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? - unless InternalId.usages.keys.include?(usage.to_s) + unless InternalId.usages.has_key?(usage.to_s) raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages" end end @@ -85,7 +86,7 @@ class InternalId < ActiveRecord::Base def generate subject.transaction do # Create a record in internal_ids if one does not yet exist - # and increment it's last value + # and increment its last value # # Note this will acquire a ROW SHARE lock on the InternalId record (lookup || create_record).increment_and_save! diff --git a/config/initializers/ar_native_database_types.rb b/config/initializers/ar_native_database_types.rb index d7b4b348957..3522b1db536 100644 --- a/config/initializers/ar_native_database_types.rb +++ b/config/initializers/ar_native_database_types.rb @@ -4,7 +4,7 @@ module ActiveRecord module ConnectionAdapters class AbstractMysqlAdapter NATIVE_DATABASE_TYPES.merge!( - bigserial: { name: 'bigint(20) auto_increment PRIMARY KEY' } + bigserial: { name: 'bigint(20) auto_increment PRIMARY KEY' } ) end end diff --git a/db/migrate/20180305095250_create_internal_ids_table.rb b/db/migrate/20180305095250_create_internal_ids_table.rb index e972432fb98..432086fe98b 100644 --- a/db/migrate/20180305095250_create_internal_ids_table.rb +++ b/db/migrate/20180305095250_create_internal_ids_table.rb @@ -3,7 +3,7 @@ class CreateInternalIdsTable < ActiveRecord::Migration DOWNTIME = false - def up + def change create_table :internal_ids, id: :bigserial do |t| t.references :project, null: false, foreign_key: { on_delete: :cascade } t.integer :usage, null: false @@ -12,8 +12,4 @@ class CreateInternalIdsTable < ActiveRecord::Migration t.index [:usage, :project_id], unique: true end end - - def down - drop_table :internal_ids - end end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index ef6db2daa95..40d777c46cc 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -41,10 +41,11 @@ describe InternalId do end it 'generates a strictly monotone, gapless sequence' do - seq = (0..rand(1000)).map do + seq = (0..rand(100)).map do described_class.generate_next(issue, scope, usage, init) end normalized = seq.map { |i| i - seq.min } + expect(normalized).to eq((0..seq.size - 1).to_a) end @@ -58,6 +59,7 @@ describe InternalId do it 'calculates next internal ids on the fly' do val = rand(1..100) + expect(init).to receive(:call).with(issue).and_return(val) expect(subject).to eq(val + 1) end @@ -70,11 +72,13 @@ describe InternalId do it 'returns incremented iid' do value = id.last_value + expect(subject).to eq(value + 1) end it 'saves the record' do subject + expect(id.changed?).to be_falsey end diff --git a/spec/support/shared_examples/models/atomic_internal_id_spec.rb b/spec/support/shared_examples/models/atomic_internal_id_spec.rb index 671aa314bd6..144af4fc475 100644 --- a/spec/support/shared_examples/models/atomic_internal_id_spec.rb +++ b/spec/support/shared_examples/models/atomic_internal_id_spec.rb @@ -24,14 +24,16 @@ shared_examples_for 'AtomicInternalId' do it 'calls InternalId.generate_next and sets internal id attribute' do iid = rand(1..1000) + expect(InternalId).to receive(:generate_next).with(instance, scope_attrs, usage, any_args).and_return(iid) subject - expect(instance.public_send(internal_id_attribute)).to eq(iid) # rubocop:disable GitlabSecurity/PublicSend + expect(instance.public_send(internal_id_attribute)).to eq(iid) end it 'does not overwrite an existing internal id' do - instance.public_send("#{internal_id_attribute}=", 4711) # rubocop:disable GitlabSecurity/PublicSend - expect { subject }.not_to change { instance.public_send(internal_id_attribute) } # rubocop:disable GitlabSecurity/PublicSend + instance.public_send("#{internal_id_attribute}=", 4711) + + expect { subject }.not_to change { instance.public_send(internal_id_attribute) } end end end -- cgit v1.2.1 From 303618655433bbe61d85a52f7638377fcf529184 Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Fri, 16 Mar 2018 15:29:11 +0100 Subject: Update gettext_i18n_rails_js 1.2.0 -> 1.3 --- Gemfile | 2 +- Gemfile.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index 4f81f1ae7fc..8823537258f 100644 --- a/Gemfile +++ b/Gemfile @@ -271,7 +271,7 @@ gem 'premailer-rails', '~> 1.9.7' gem 'ruby_parser', '~> 3.8', require: false gem 'rails-i18n', '~> 4.0.9' gem 'gettext_i18n_rails', '~> 1.8.0' -gem 'gettext_i18n_rails_js', '~> 1.2.0' +gem 'gettext_i18n_rails_js', '~> 1.3' gem 'gettext', '~> 3.2.2', require: false, group: :development gem 'batch-loader', '~> 1.2.1' diff --git a/Gemfile.lock b/Gemfile.lock index 1dd8576e30b..38cc50a2e1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -210,7 +210,7 @@ GEM faraday_middleware multi_json fast_blank (1.0.0) - fast_gettext (1.4.0) + fast_gettext (1.6.0) ffaker (2.4.0) ffi (1.9.18) flay (2.10.0) @@ -276,12 +276,12 @@ GEM gemojione (3.3.0) json get_process_mem (0.2.0) - gettext (3.2.2) + gettext (3.2.9) locale (>= 2.0.5) text (>= 1.3.0) gettext_i18n_rails (1.8.0) fast_gettext (>= 0.9.0) - gettext_i18n_rails_js (1.2.0) + gettext_i18n_rails_js (1.3.0) gettext (>= 3.0.2) gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) @@ -496,7 +496,7 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mimemagic (0.3.0) - mini_mime (0.1.4) + mini_mime (1.0.0) mini_portile2 (2.3.0) minitest (5.7.0) mousetrap-rails (1.4.6) @@ -676,8 +676,8 @@ GEM sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.8) - activesupport (>= 4.2.0.beta, < 5.0) + rails-dom-testing (1.0.9) + activesupport (>= 4.2.0, < 5.0) nokogiri (~> 1.6) rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) @@ -1056,7 +1056,7 @@ DEPENDENCIES gemojione (~> 3.3) gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) - gettext_i18n_rails_js (~> 1.2.0) + gettext_i18n_rails_js (~> 1.3) gitaly-proto (~> 0.88.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) -- cgit v1.2.1 From 28a5f8c60a29445e147614767a8452b3141ef868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Fri, 16 Mar 2018 16:54:36 +0100 Subject: Use secret_key and secret_value in Variables controller --- app/controllers/groups/variables_controller.rb | 9 +++++++-- app/controllers/projects/variables_controller.rb | 9 +++++++-- .../controllers/variables_shared_examples.rb | 16 ++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index cb8771bc97e..2794d5fe6ec 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -35,11 +35,16 @@ module Groups end def group_variables_params - params.permit(variables_attributes: [*variable_params_attributes]) + filtered_params = params.permit(variables_attributes: [*variable_params_attributes]) + filtered_params["variables_attributes"].each do |variable| + variable["key"] = variable.delete("secret_key") + variable["value"] = variable.delete("secret_value") + end + filtered_params end def variable_params_attributes - %i[id key value protected _destroy] + %i[id secret_key secret_value protected _destroy] end def authorize_admin_build! diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 7eb509e2e64..3cbfe7b3cc1 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -32,10 +32,15 @@ class Projects::VariablesController < Projects::ApplicationController end def variables_params - params.permit(variables_attributes: [*variable_params_attributes]) + filtered_params = params.permit(variables_attributes: [*variable_params_attributes]) + filtered_params["variables_attributes"].each do |variable| + variable["key"] = variable.delete("secret_key") + variable["value"] = variable.delete("secret_value") + end + filtered_params end def variable_params_attributes - %i[id key value protected _destroy] + %i[id secret_key secret_value protected _destroy] end end diff --git a/spec/support/shared_examples/controllers/variables_shared_examples.rb b/spec/support/shared_examples/controllers/variables_shared_examples.rb index d7acf8c0032..7c7e345f715 100644 --- a/spec/support/shared_examples/controllers/variables_shared_examples.rb +++ b/spec/support/shared_examples/controllers/variables_shared_examples.rb @@ -15,21 +15,21 @@ end shared_examples 'PATCH #update updates variables' do let(:variable_attributes) do { id: variable.id, - key: variable.key, - value: variable.value, + secret_key: variable.key, + secret_value: variable.value, protected: variable.protected?.to_s } end let(:new_variable_attributes) do - { key: 'new_key', - value: 'dummy_value', + { secret_key: 'new_key', + secret_value: 'dummy_value', protected: 'false' } end context 'with invalid new variable parameters' do let(:variables_attributes) do [ - variable_attributes.merge(value: 'other_value'), - new_variable_attributes.merge(key: '...?') + variable_attributes.merge(secret_value: 'other_value'), + new_variable_attributes.merge(secret_key: '...?') ] end @@ -52,7 +52,7 @@ shared_examples 'PATCH #update updates variables' do let(:variables_attributes) do [ new_variable_attributes, - new_variable_attributes.merge(value: 'other_value') + new_variable_attributes.merge(secret_value: 'other_value') ] end @@ -74,7 +74,7 @@ shared_examples 'PATCH #update updates variables' do context 'with valid new variable parameters' do let(:variables_attributes) do [ - variable_attributes.merge(value: 'other_value'), + variable_attributes.merge(secret_value: 'other_value'), new_variable_attributes ] end -- cgit v1.2.1 From 763c82f0b304362be71ca82e551381609db19bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Fri, 16 Mar 2018 16:59:06 +0100 Subject: Use secret_key and secret_value in variable form field names --- app/views/ci/variables/_variable_row.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 15201780451..e72e48385da 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -9,8 +9,8 @@ - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" -- key_input_name = "#{form_field}[variables_attributes][][key]" -- value_input_name = "#{form_field}[variables_attributes][][value]" +- key_input_name = "#{form_field}[variables_attributes][][secret_key]" +- value_input_name = "#{form_field}[variables_attributes][][secret_value]" - protected_input_name = "#{form_field}[variables_attributes][][protected]" %li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } -- cgit v1.2.1 From bb226a294b12bc0430fe506bef5baea01a26de99 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Fri, 16 Mar 2018 15:35:58 +0000 Subject: Ensure that we never assume old_blob or new_blob are nil These can be a `BatchLoader` which is proxying a nil, while not being concrete nils themselves. --- ...ves-500-error-error-undefined-method-binary.yml | 5 +++ lib/gitlab/diff/file.rb | 39 ++++++++++++---------- spec/lib/gitlab/diff/file_spec.rb | 12 +++++++ 3 files changed, 38 insertions(+), 18 deletions(-) create mode 100644 changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml diff --git a/changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml b/changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml new file mode 100644 index 00000000000..934860b95fe --- /dev/null +++ b/changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml @@ -0,0 +1,5 @@ +--- +title: Fix viewing diffs on old merge requests +merge_request: 17805 +author: +type: fixed diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index 34b070dd375..014854da55c 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -27,8 +27,8 @@ module Gitlab @fallback_diff_refs = fallback_diff_refs # Ensure items are collected in the the batch - new_blob - old_blob + new_blob_lazy + old_blob_lazy end def position(position_marker, position_type: :text) @@ -101,25 +101,19 @@ module Gitlab end def new_blob - return unless new_content_sha - - Blob.lazy(repository.project, new_content_sha, file_path) + new_blob_lazy&.itself end def old_blob - return unless old_content_sha - - Blob.lazy(repository.project, old_content_sha, old_path) + old_blob_lazy&.itself end def content_sha new_content_sha || old_content_sha end - # Use #itself to check the value wrapped by a BatchLoader instance, rather - # than if the BatchLoader instance itself is falsey. def blob - new_blob&.itself || old_blob&.itself + new_blob || old_blob end attr_writer :highlighted_diff_lines @@ -237,17 +231,14 @@ module Gitlab private - # The blob instances are instances of BatchLoader, which means calling - # &. directly on them won't work. Object#try also won't work, because Blob - # doesn't inherit from Object, but from BasicObject (via SimpleDelegator). + # We can't use Object#try because Blob doesn't inherit from Object, but + # from BasicObject (via SimpleDelegator). def try_blobs(meth) - old_blob&.itself&.public_send(meth) || new_blob&.itself&.public_send(meth) + old_blob&.public_send(meth) || new_blob&.public_send(meth) end - # We can't use #compact for the same reason we can't use &., but calling - # #nil? explicitly does work because it is proxied to the blob itself. def valid_blobs - [old_blob, new_blob].reject(&:nil?) + [old_blob, new_blob].compact end def text_position_properties(line) @@ -262,6 +253,18 @@ module Gitlab old_blob && new_blob && old_blob.id != new_blob.id end + def new_blob_lazy + return unless new_content_sha + + Blob.lazy(repository.project, new_content_sha, file_path) + end + + def old_blob_lazy + return unless old_content_sha + + Blob.lazy(repository.project, old_content_sha, old_path) + end + def simple_viewer_class return DiffViewer::NotDiffable unless diffable? diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 9204ea37963..0c2e18c268a 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -455,5 +455,17 @@ describe Gitlab::Diff::File do expect(diff_file.size).to be_zero end end + + describe '#different_type?' do + it 'returns false' do + expect(diff_file).not_to be_different_type + end + end + + describe '#content_changed?' do + it 'returns false' do + expect(diff_file).not_to be_content_changed + end + end end end -- cgit v1.2.1 From 6909cc2ea393ad5aeea88d0306ff8d922e67bdda Mon Sep 17 00:00:00 2001 From: Mark Fletcher Date: Fri, 16 Mar 2018 19:06:23 +0000 Subject: Update CHANGELOG.md for 10.3.9 [ci skip] --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c64e68967e..f421bb8c031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -492,6 +492,15 @@ entry. - Use a background migration for issues.closed_at. +## 10.3.9 (2018-03-16) + +### Security (3 changes) + +- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337 +- Update nokogiri to 1.8.2. !16807 +- Fix GitLab Auth0 integration signing in the wrong user. + + ## 10.3.8 (2018-03-01) ### Security (1 change) -- cgit v1.2.1 From f9c803a7c826b8ef3fc0ac61e9ba0a17f12012c5 Mon Sep 17 00:00:00 2001 From: Mark Fletcher Date: Fri, 16 Mar 2018 19:14:11 +0000 Subject: Update CHANGELOG.md for 10.4.6 [ci skip] --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f421bb8c031..ad6dd08e801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,6 +261,14 @@ entry. - Adds empty state illustration for pending job. +## 10.4.6 (2018-03-16) + +### Security (2 changes) + +- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337 +- Fix GitLab Auth0 integration signing in the wrong user. + + ## 10.4.5 (2018-03-01) ### Security (1 change) -- cgit v1.2.1 From 5d558a825918640e6b6b38641af6a4cb6abe87de Mon Sep 17 00:00:00 2001 From: Mark Fletcher Date: Fri, 16 Mar 2018 19:21:13 +0000 Subject: Update CHANGELOG.md for 10.5.6 [ci skip] --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6dd08e801..76e576ee38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.5.6 (2018-03-16) + +### Security (2 changes) + +- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337 +- Fix GitLab Auth0 integration signing in the wrong user. + + ## 10.5.5 (2018-03-15) ### Fixed (3 changes) -- cgit v1.2.1 From ca63603d052808b7a5ab9b04acc611215e23a8c3 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 16 Mar 2018 23:14:12 -0700 Subject: Fix "Can't modify frozen hash" error when project is destroyed Partial fix to #44378 --- app/services/projects/destroy_service.rb | 6 +++++- changelogs/unreleased/sh-fix-failure-project-destroy.yml | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/sh-fix-failure-project-destroy.yml diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 81972df9b3c..4b8f955ae69 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -88,7 +88,11 @@ module Projects def attempt_rollback(project, message) return unless project - project.update_attributes(delete_error: message, pending_delete: false) + # It's possible that the project was destroyed, but some after_commit + # hook failed and caused us to end up here. A destroyed model will be a frozen hash, + # which cannot be altered. + project.update_attributes(delete_error: message, pending_delete: false) unless project.destroyed? + log_error("Deletion failed on #{project.full_path} with the following message: #{message}") end diff --git a/changelogs/unreleased/sh-fix-failure-project-destroy.yml b/changelogs/unreleased/sh-fix-failure-project-destroy.yml new file mode 100644 index 00000000000..d5f5cd3f954 --- /dev/null +++ b/changelogs/unreleased/sh-fix-failure-project-destroy.yml @@ -0,0 +1,5 @@ +--- +title: Fix "Can't modify frozen hash" error when project is destroyed +merge_request: +author: +type: fixed -- cgit v1.2.1 From 53915c5c54c06182717b457375ae771ee01558fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sat, 17 Mar 2018 12:17:40 +0100 Subject: Alias secret_key and secret_value to key and value --- app/controllers/groups/variables_controller.rb | 7 +------ app/controllers/projects/variables_controller.rb | 7 +------ app/models/ci/group_variable.rb | 3 +++ app/models/ci/variable.rb | 3 +++ 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 2794d5fe6ec..91e394c8ce8 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -35,12 +35,7 @@ module Groups end def group_variables_params - filtered_params = params.permit(variables_attributes: [*variable_params_attributes]) - filtered_params["variables_attributes"].each do |variable| - variable["key"] = variable.delete("secret_key") - variable["value"] = variable.delete("secret_value") - end - filtered_params + params.permit(variables_attributes: [*variable_params_attributes]) end def variable_params_attributes diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 3cbfe7b3cc1..ffe93522ca6 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -32,12 +32,7 @@ class Projects::VariablesController < Projects::ApplicationController end def variables_params - filtered_params = params.permit(variables_attributes: [*variable_params_attributes]) - filtered_params["variables_attributes"].each do |variable| - variable["key"] = variable.delete("secret_key") - variable["value"] = variable.delete("secret_value") - end - filtered_params + params.permit(variables_attributes: [*variable_params_attributes]) end def variable_params_attributes diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 1dd0e050ba9..65399557289 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -6,6 +6,9 @@ module Ci belongs_to :group + alias_attribute :secret_key, :key + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: :group_id, message: "(%{value}) has already been taken" diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 7c71291de84..bcad55f115f 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -6,6 +6,9 @@ module Ci belongs_to :project + alias_attribute :secret_key, :key + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: [:project_id, :environment_scope], message: "(%{value}) has already been taken" -- cgit v1.2.1 From 2b4e5c938b5678592ecbbabe6dcbca0731a51fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sat, 17 Mar 2018 12:37:48 +0100 Subject: Use secret_key and secret_value in CI variable frontend --- app/assets/javascripts/ci_variable_list/ci_variable_list.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index 745f3404295..c0bfe615478 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -29,11 +29,11 @@ export default class VariableList { selector: '.js-ci-variable-input-id', default: '', }, - key: { + secret_key: { selector: '.js-ci-variable-input-key', default: '', }, - value: { + secret_value: { selector: '.js-ci-variable-input-value', default: '', }, @@ -105,7 +105,7 @@ export default class VariableList { setupToggleButtons($row[0]); // Reset the resizable textarea - $row.find(this.inputMap.value.selector).css('height', ''); + $row.find(this.inputMap.secret_value.selector).css('height', ''); const $environmentSelect = $row.find('.js-variable-environment-toggle'); if ($environmentSelect.length) { @@ -174,7 +174,7 @@ export default class VariableList { } toggleEnableRow(isEnabled = true) { - this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); + this.$container.find(this.inputMap.secret_key.selector).attr('disabled', !isEnabled); this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); } -- cgit v1.2.1 From ddeefbdd24119fff5bb3c770f9a285f28ca31914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sat, 17 Mar 2018 12:40:57 +0100 Subject: Filter secret CI variable values from logs --- config/application.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/application.rb b/config/application.rb index 0ff95e33a9c..c14f875611c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -70,6 +70,7 @@ module Gitlab # - Webhook URLs (:hook) # - Sentry DSN (:sentry_dsn) # - Deploy keys (:key) + # - Secret variable values (:secret_value) config.filter_parameters += [/token$/, /password/, /secret/] config.filter_parameters += %i( certificate @@ -81,6 +82,7 @@ module Gitlab sentry_dsn trace variables + secret_value ) # Enable escaping HTML in JSON. -- cgit v1.2.1 From 30d685b59c716fffbff4ddfbd27530f861a5f0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sat, 17 Mar 2018 16:12:13 +0100 Subject: Add CHANGELOG --- ...andling-sensitive-information-should-use-a-more-specific-name.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml diff --git a/changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml b/changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml new file mode 100644 index 00000000000..de1cee6e436 --- /dev/null +++ b/changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml @@ -0,0 +1,5 @@ +--- +title: Use specific names for filtered CI variable controller parameters +merge_request: 17796 +author: +type: other -- cgit v1.2.1 From 68c6e410bd02207b621f9339edf3fc53d0bde7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sat, 17 Mar 2018 16:18:36 +0100 Subject: Use secret_key and secret_value in Pipeline Schedule variables --- .../projects/pipeline_schedules_controller.rb | 2 +- app/models/ci/pipeline_schedule_variable.rb | 3 +++ .../projects/pipeline_schedules_controller_spec.rb | 18 ++++++++++-------- spec/features/projects/pipeline_schedules_spec.rb | 8 ++++---- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index b478e7b5e05..6c087dfb71e 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -92,7 +92,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def schedule_params params.require(:schedule) .permit(:description, :cron, :cron_timezone, :ref, :active, - variables_attributes: [:id, :key, :value, :_destroy] ) + variables_attributes: [:id, :secret_key, :secret_value, :_destroy] ) end def authorize_play_pipeline_schedule! diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb index af989fb14b4..2e30612a88e 100644 --- a/app/models/ci/pipeline_schedule_variable.rb +++ b/app/models/ci/pipeline_schedule_variable.rb @@ -5,6 +5,9 @@ module Ci belongs_to :pipeline_schedule + alias_attribute :secret_key, :key + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: :pipeline_schedule_id } end end diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 966ffdf6996..11d0c41fe76 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -80,7 +80,7 @@ describe Projects::PipelineSchedulesController do context 'when variables_attributes has one variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }] + variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }] }) end @@ -101,7 +101,8 @@ describe Projects::PipelineSchedulesController do context 'when variables_attributes has two variables and duplicated' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }] + variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }, + { secret_key: 'AAA', secret_value: 'BBB123' }] }) end @@ -152,7 +153,7 @@ describe Projects::PipelineSchedulesController do context 'when params include one variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }] + variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }] }) end @@ -169,7 +170,8 @@ describe Projects::PipelineSchedulesController do context 'when params include two duplicated variables' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }] + variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }, + { secret_key: 'AAA', secret_value: 'BBB123' }] }) end @@ -194,7 +196,7 @@ describe Projects::PipelineSchedulesController do context 'when adds a new variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }] + variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }] }) end @@ -209,7 +211,7 @@ describe Projects::PipelineSchedulesController do context 'when adds a new duplicated variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'CCC', value: 'AAA123' }] + variables_attributes: [{ secret_key: 'CCC', secret_value: 'AAA123' }] }) end @@ -224,7 +226,7 @@ describe Projects::PipelineSchedulesController do context 'when updates a variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ id: pipeline_schedule_variable.id, value: 'new_value' }] + variables_attributes: [{ id: pipeline_schedule_variable.id, secret_value: 'new_value' }] }) end @@ -252,7 +254,7 @@ describe Projects::PipelineSchedulesController do let(:schedule) do basic_param.merge({ variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true }, - { key: 'CCC', value: 'CCC123' }] + { secret_key: 'CCC', secret_value: 'CCC123' }] }) end diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 65e24862d43..0c9aa2d1497 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -159,10 +159,10 @@ feature 'Pipeline Schedules', :js do visit_pipelines_schedules click_link 'New schedule' fill_in_schedule_form - all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') - all('[name="schedule[variables_attributes][][value]"]')[0].set('AAA123') - all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') - all('[name="schedule[variables_attributes][][value]"]')[1].set('BBB123') + all('[name="schedule[variables_attributes][][secret_key]"]')[0].set('AAA') + all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') + all('[name="schedule[variables_attributes][][secret_key]"]')[1].set('BBB') + all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') save_pipeline_schedule end -- cgit v1.2.1 From 67fc0a2b92f49250b14562a04539b775f0d55cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Sat, 17 Mar 2018 18:33:03 +0100 Subject: Check for secret_key and secret_value in CI Variable native list js spec --- spec/javascripts/ci_variable_list/native_form_variable_list_spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js index 1ea8d86cb7e..d3bcbdd92c1 100644 --- a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js @@ -19,8 +19,8 @@ describe('NativeFormVariableList', () => { describe('onFormSubmit', () => { it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { const $row = $wrapper.find('.js-row'); - expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][key]'); - expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][value]'); + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][secret_key]'); + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][secret_value]'); $wrapper.closest('form').trigger('trigger-submit'); -- cgit v1.2.1 From 7e0635bde058278aa76110780caf359eeca3ea86 Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 18 Mar 2018 04:15:50 +0900 Subject: Unify format for nested non-task lists --- app/assets/stylesheets/pages/notes.scss | 6 ------ changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 085a2e74328..81e98f358a8 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -140,12 +140,6 @@ ul.notes { @include bulleted-list; word-wrap: break-word; - ul.task-list { - ul:not(.task-list) { - padding-left: 1.3em; - } - } - table { @include markdown-table; } diff --git a/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml b/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml new file mode 100644 index 00000000000..79c470ea4e1 --- /dev/null +++ b/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml @@ -0,0 +1,5 @@ +--- +title: Unify format for nested non-task lists +merge_request: 17823 +author: Takuya Noguchi +type: fixed -- cgit v1.2.1 From 5c3316c21f0f144f771046849404f7776be5902b Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sun, 18 Mar 2018 12:19:25 +1100 Subject: [Rails5] Update active_record_locking initializer --- config/initializers/active_record_locking.rb | 108 ++++++++++++++------------- 1 file changed, 56 insertions(+), 52 deletions(-) diff --git a/config/initializers/active_record_locking.rb b/config/initializers/active_record_locking.rb index 150aaa2a8c2..3e7111fd063 100644 --- a/config/initializers/active_record_locking.rb +++ b/config/initializers/active_record_locking.rb @@ -1,73 +1,77 @@ # rubocop:disable Lint/RescueException -# This patch fixes https://github.com/rails/rails/issues/26024 -# TODO: Remove it when it's no longer necessary - -module ActiveRecord - module Locking - module Optimistic - # We overwrite this method because we don't want to have default value - # for newly created records - def _create_record(attribute_names = self.attribute_names, *) # :nodoc: - super - end +# Remove this entire initializer when we are at rails 5.0. +# This file fixes the bug (see below) which has been fixed in the upstream. +unless Gitlab.rails5? + # This patch fixes https://github.com/rails/rails/issues/26024 + # TODO: Remove it when it's no longer necessary + + module ActiveRecord + module Locking + module Optimistic + # We overwrite this method because we don't want to have default value + # for newly created records + def _create_record(attribute_names = self.attribute_names, *) # :nodoc: + super + end - def _update_record(attribute_names = self.attribute_names) #:nodoc: - return super unless locking_enabled? - return 0 if attribute_names.empty? + def _update_record(attribute_names = self.attribute_names) #:nodoc: + return super unless locking_enabled? + return 0 if attribute_names.empty? - lock_col = self.class.locking_column + lock_col = self.class.locking_column - previous_lock_value = send(lock_col).to_i # rubocop:disable GitlabSecurity/PublicSend + previous_lock_value = send(lock_col).to_i # rubocop:disable GitlabSecurity/PublicSend - # This line is added as a patch - previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0 + # This line is added as a patch + previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0 - increment_lock + increment_lock - attribute_names += [lock_col] - attribute_names.uniq! + attribute_names += [lock_col] + attribute_names.uniq! - begin - relation = self.class.unscoped + begin + relation = self.class.unscoped - affected_rows = relation.where( - self.class.primary_key => id, - lock_col => previous_lock_value - ).update_all( - attributes_for_update(attribute_names).map do |name| - [name, _read_attribute(name)] - end.to_h - ) + affected_rows = relation.where( + self.class.primary_key => id, + lock_col => previous_lock_value + ).update_all( + attributes_for_update(attribute_names).map do |name| + [name, _read_attribute(name)] + end.to_h + ) - unless affected_rows == 1 - raise ActiveRecord::StaleObjectError.new(self, "update") - end + unless affected_rows == 1 + raise ActiveRecord::StaleObjectError.new(self, "update") + end - affected_rows + affected_rows - # If something went wrong, revert the version. - rescue Exception - send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend - raise + # If something went wrong, revert the version. + rescue Exception + send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend + raise + end end - end - # This is patched because we need it to query `lock_version IS NULL` - # rather than `lock_version = 0` whenever lock_version is NULL. - def relation_for_destroy - return super unless locking_enabled? + # This is patched because we need it to query `lock_version IS NULL` + # rather than `lock_version = 0` whenever lock_version is NULL. + def relation_for_destroy + return super unless locking_enabled? - column_name = self.class.locking_column - super.where(self.class.arel_table[column_name].eq(self[column_name])) + column_name = self.class.locking_column + super.where(self.class.arel_table[column_name].eq(self[column_name])) + end end - end - # This is patched because we want `lock_version` default to `NULL` - # rather than `0` - class LockingType < SimpleDelegator - def type_cast_from_database(value) - super + # This is patched because we want `lock_version` default to `NULL` + # rather than `0` + class LockingType < SimpleDelegator + def type_cast_from_database(value) + super + end end end end -- cgit v1.2.1 From e7393191ee209beb1d39727384e4be21434415c6 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Sun, 18 Mar 2018 17:00:41 +0100 Subject: Replace public_send calls. --- app/models/concerns/atomic_internal_id.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 6895c7d7e95..4b66725a3e6 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,12 +27,12 @@ module AtomicInternalId module ClassMethods def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName before_validation(on: :create) do - if self.public_send(column).blank? # rubocop:disable GitlabSecurity/PublicSend - scope_attrs = { scope => self.public_send(scope) } # rubocop:disable GitlabSecurity/PublicSend + if read_attribute(column).blank? + scope_attrs = { scope => association(scope).reader } usage = self.class.table_name.to_sym new_iid = InternalId.generate_next(self, scope_attrs, usage, init) - self.public_send("#{column}=", new_iid) # rubocop:disable GitlabSecurity/PublicSend + write_attribute(column, new_iid) end end -- cgit v1.2.1 From c3b489bdcb1d6cf8bcdb935c73892dc5465eacbd Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Sun, 18 Mar 2018 17:13:09 +0100 Subject: Add spec for concurrent insert situation. --- spec/models/internal_id_spec.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index 40d777c46cc..581fd0293cc 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -38,6 +38,19 @@ describe InternalId do expect(subject).to eq(project.issues.size + 1) end end + + context 'with concurrent inserts on table' do + it 'looks up the record if it was created concurrently' do + args = { **scope, usage: described_class.usages[usage.to_s] } + record = double + expect(described_class).to receive(:find_by).with(args).and_return(nil) # first call, record not present + expect(described_class).to receive(:find_by).with(args).and_return(record) # second call, record was created by another process + expect(described_class).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique, 'record not unique') + expect(record).to receive(:increment_and_save!) + + subject + end + end end it 'generates a strictly monotone, gapless sequence' do -- cgit v1.2.1 From 8d32477884dcbc79ac0dd655ecf2fa27ceb073a8 Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Mon, 19 Mar 2018 08:03:41 +0900 Subject: Update rack-protection to 2.0.1 --- Gemfile.lock | 2 +- changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml diff --git a/Gemfile.lock b/Gemfile.lock index 1dd8576e30b..1432e4ab75e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -657,7 +657,7 @@ GEM httpclient (>= 2.4) multi_json (>= 1.3.6) rack (>= 1.1) - rack-protection (1.5.3) + rack-protection (2.0.1) rack rack-proxy (0.6.0) rack diff --git a/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml b/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml new file mode 100644 index 00000000000..c21d02d4d87 --- /dev/null +++ b/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml @@ -0,0 +1,5 @@ +--- +title: Update rack-protection to 2.0.1 +merge_request: 17835 +author: Takuya Noguchi +type: security -- cgit v1.2.1 From 0a5c202eabe07a55f7851d5f1fa3ae1c8e5b56ba Mon Sep 17 00:00:00 2001 From: Joshua Lambert Date: Sun, 18 Mar 2018 20:22:49 -0400 Subject: Minor updates --- doc/user/project/integrations/prometheus.md | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index 249463fb86e..fa7e504c4aa 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -2,7 +2,7 @@ > [Introduced][ce-8935] in GitLab 9.0. -GitLab offers powerful integration with [Prometheus] for monitoring key metrics your apps, directly within GitLab. +GitLab offers powerful integration with [Prometheus] for monitoring key metrics of your apps, directly within GitLab. Metrics for each environment are retrieved from Prometheus, and then displayed within the GitLab interface. @@ -12,17 +12,21 @@ There are two ways to setup Prometheus integration, depending on where your apps * For deployments on Kubernetes, GitLab can automatically [deploy and manage Prometheus](#managed-prometheus-on-kubernetes) * For other deployment targets, simply [specify the Prometheus server](#manual-configuration-of-prometheus). -## Managed Prometheus on Kubernetes +Once enabled, GitLab will automatically detect metrics from known services in the [metric library](#monitoring-ci-cd-environments). + +## Enabling Prometheus Integration + +### Managed Prometheus on Kubernetes > **Note**: [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/28916) in GitLab 10.5 GitLab can seamlessly deploy and manage Prometheus on a [connected Kubernetes cluster](../clusters/index.md), making monitoring of your apps easy. -### Requirements +#### Requirements * A [connected Kubernetes cluster](../clusters/index.md) * Helm Tiller [installed by GitLab](../clusters/index.md#installing-applications) -### Getting started +#### Getting started Once you have a connected Kubernetes cluster with Helm installed, deploying a managed Prometheus is as easy as a single click. @@ -32,7 +36,7 @@ Once you have a connected Kubernetes cluster with Helm installed, deploying a ma ![Managed Prometheus Deploy](img/prometheus_deploy.png) -### About managed Prometheus deployments +#### About managed Prometheus deployments Prometheus is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/kubernetes/charts/tree/master/stable/prometheus). Prometheus is only accessible within the cluster, with GitLab communicating through the [Kubernetes API](https://kubernetes.io/docs/concepts/overview/kubernetes-api/). @@ -45,9 +49,9 @@ CPU and Memory consumption is monitored, but requires [naming conventions](prome The [NGINX Ingress](../clusters/index.md#installing-applications) that is deployed by GitLab to clusters, is automatically annotated for monitoring providing key response metrics: latency, throughput, and error rates. -## Manual configuration of Prometheus +### Manual configuration of Prometheus -### Requirements +#### Requirements Integration with Prometheus requires the following: @@ -56,7 +60,7 @@ Integration with Prometheus requires the following: 1. Each metric must be have a label to indicate the environment 1. GitLab must have network connectivity to the Prometheus server -### Getting started +#### Getting started Installing and configuring Prometheus to monitor applications is fairly straight forward. @@ -64,7 +68,7 @@ Installing and configuring Prometheus to monitor applications is fairly straight 1. Set up one of the [supported monitoring targets](prometheus_library/metrics.md) 1. Configure the Prometheus server to [collect their metrics](https://prometheus.io/docs/operating/configuration/#scrape_config) -### Configuration in GitLab +#### Configuration in GitLab The actual configuration of Prometheus integration within GitLab is very simple. All you will need is the DNS or IP address of the Prometheus server you'd like @@ -83,9 +87,9 @@ to integrate with. Once configured, GitLab will attempt to retrieve performance metrics for any environment which has had a successful deployment. -GitLab will automatically scan the Prometheus server for known metrics and attempt to identify the metrics for a particular environment. The supported metrics and scan process is detailed in our [Prometheus Metric Library documentation](prometheus_library/metrics.html). +GitLab will automatically scan the Prometheus server for metrics from known serves like Kubernetes and NGINX, and attempt to identify individual environment. The supported metrics and scan process is detailed in our [Prometheus Metric Library documentation](prometheus_library/metrics.html). -[Learn more about monitoring environments.](../../../ci/environments.md#monitoring-environments) +You can view the performance dashboard for an environment by [clicking on the monitoring button](../../../ci/environments.md#monitoring-environments). ## Determining the performance impact of a merge @@ -93,7 +97,7 @@ GitLab will automatically scan the Prometheus server for known metrics and attem > GitLab 9.3 added the [numeric comparison](https://gitlab.com/gitlab-org/gitlab-ce/issues/27439) of the 30 minute averages. > Requires [Kubernetes](prometheus_library/kubernetes.md) metrics -Developers can view theperformance impact of their changes within the merge +Developers can view the performance impact of their changes within the merge request workflow. When a source branch has been deployed to an environment, a sparkline and numeric comparison of the average memory consumption will appear. On the sparkline, a dot indicates when the current changes were deployed, with up to 30 minutes of performance data displayed before and after. The comparison shows the difference between the 30 minute average before and after the deployment. This information is updated after @@ -109,7 +113,7 @@ Prometheus server. ## Troubleshooting -If the "Attempting to load performance data" screen continues to appear, it could be due to: +If the "No data found" screen continues to appear, it could be due to: - No successful deployments have occurred to this environment. - Prometheus does not have performance data for this environment, or the metrics -- cgit v1.2.1 From 573eec55848e88b517747e815e54526d7041a6ce Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Mon, 19 Mar 2018 10:35:01 +1100 Subject: Add documentation for displayed K8s Ingress IP address (#44330) --- changelogs/unreleased/44330-docs-for-ingress-ip.yml | 5 +++++ doc/user/project/clusters/index.md | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 changelogs/unreleased/44330-docs-for-ingress-ip.yml diff --git a/changelogs/unreleased/44330-docs-for-ingress-ip.yml b/changelogs/unreleased/44330-docs-for-ingress-ip.yml new file mode 100644 index 00000000000..3dfaea6e17e --- /dev/null +++ b/changelogs/unreleased/44330-docs-for-ingress-ip.yml @@ -0,0 +1,5 @@ +--- +title: Add documentation for displayed K8s Ingress IP address (#44330) +merge_request: 17836 +author: +type: other diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 661697aaeb7..8da4fd8a1f5 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -167,6 +167,19 @@ external IP address with the following procedure. It can be deployed using the In order to publish your web application, you first need to find the external IP address associated to your load balancer. +### GitLab can automatically determine the IP address for you + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17052) in GitLab 10.6. + +If you installed the Ingress [via the +**Applications**](#installing-applications) you should see the Ingress IP address on +this same page within a few minutes. There may be some +instances in which GitLab cannot determine the IP address of your ingress +application in which case you can read on for other ways of manually +determining the IP address. + +### Manually determining the IP address + If the cluster is on GKE, click on the **Google Kubernetes Engine** link in the **Advanced settings**, or go directly to the [Google Kubernetes Engine dashboard](https://console.cloud.google.com/kubernetes/) -- cgit v1.2.1 From 1a9ae286fab605f897f26644d76ef60d03be17e3 Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Mon, 19 Mar 2018 11:16:18 +1100 Subject: Add documentation for runner IP address (#44232) --- .../44232-docs-for-runner-ip-address.yml | 5 ++++ doc/ci/runners/README.md | 33 +++++++++++++++++++++ doc/ci/runners/img/shared_runner_ip_address.png | Bin 0 -> 69821 bytes doc/ci/runners/img/specific_runner_ip_address.png | Bin 0 -> 42055 bytes 4 files changed, 38 insertions(+) create mode 100644 changelogs/unreleased/44232-docs-for-runner-ip-address.yml create mode 100644 doc/ci/runners/img/shared_runner_ip_address.png create mode 100644 doc/ci/runners/img/specific_runner_ip_address.png diff --git a/changelogs/unreleased/44232-docs-for-runner-ip-address.yml b/changelogs/unreleased/44232-docs-for-runner-ip-address.yml new file mode 100644 index 00000000000..82485d31b24 --- /dev/null +++ b/changelogs/unreleased/44232-docs-for-runner-ip-address.yml @@ -0,0 +1,5 @@ +--- +title: Add documentation for runner IP address (#44232) +merge_request: 17837 +author: +type: other diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index f879ed62010..9f2538b9c9f 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -280,3 +280,36 @@ We're always looking for contributions that can mitigate these [register]: http://docs.gitlab.com/runner/register/ [protected branches]: ../../user/project/protected_branches.md [protected tags]: ../../user/project/protected_tags.md + +## Determining the IP address of a Runner + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17286) in GitLab 10.6. + +It may be useful to know the IP address of a Runner so you can troubleshoot +issues with that Runner. GitLab stores and displays the IP address by viewing +the source of the HTTP requests it makes to GitLab when polling for jobs. The +IP address is always kept up to date so if the Runner IP changes it will be +automatically updated in GitLab. + +The IP address for shared Runners and specific Runners can be found in +different places. + +### Shared Runners + +To view the IP address of a shared Runner you must have admin access to +the GitLab instance. To determine this: + +1. Visit **Admin area âž” Overview âž” Runners** +1. Look for the Runner in the table and you should see a column for "IP Address" + +![shared Runner IP address](img/shared_runner_ip_address.png) + +### Specific Runners + +You can find the IP address of a Runner for a specific project by: + +1. Visit your project's **Settings âž” CI/CD** +1. Find the Runner and click on it's ID which links you to the details page +1. On the details page you should see a row for "IP Address" + +![specific Runner IP address](img/specific_runner_ip_address.png) diff --git a/doc/ci/runners/img/shared_runner_ip_address.png b/doc/ci/runners/img/shared_runner_ip_address.png new file mode 100644 index 00000000000..3b1542d59d3 Binary files /dev/null and b/doc/ci/runners/img/shared_runner_ip_address.png differ diff --git a/doc/ci/runners/img/specific_runner_ip_address.png b/doc/ci/runners/img/specific_runner_ip_address.png new file mode 100644 index 00000000000..3b4c3e9f2eb Binary files /dev/null and b/doc/ci/runners/img/specific_runner_ip_address.png differ -- cgit v1.2.1 From c7a55170d31e5e6ff6f36a214f214dcd29cd6249 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Mon, 19 Mar 2018 09:48:30 +0100 Subject: Copyedit CI services docs --- doc/ci/docker/using_docker_images.md | 89 +++++++++++++----------------------- 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index 2f336b1eb78..bc5d3840368 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -58,7 +58,7 @@ your job and is linked to the Docker image that the `image` keyword defines. This allows you to access the service image during build time. The service image can run any application, but the most common use case is to -run a database container, eg. `mysql`. It's easier and faster to use an +run a database container, e.g., `mysql`. It's easier and faster to use an existing image and run it as an additional container than install `mysql` every time the project is built. @@ -83,42 +83,46 @@ So, in order to access your database service you have to connect to the host named `mysql` instead of a socket or `localhost`. Read more in [accessing the services](#accessing-the-services). -### How service health check works +### How the health check of services works Services are designed to provide additional functionality which is **network accessible**. -It may be a database (like mysql, but also like redis), this may be docker:dind (which -allows you to use Docker). This may be anything else that is required for the CI/CD job -to proceed and what is accessed by network. +It may be a database like MySQL, or Redis, and even `docker:dind` which +allows you to use Docker in Docker. It can be practically anything that is +required for the CI/CD job to proceed and is accessed by network. -To make sure this works, Runner is: +To make sure this works, the Runner: -1. checking which ports are exposed from the container by default, -1. starts a special container that waits for these ports to be accessible. +1. checks which ports are exposed from the container by default +1. starts a special container that waits for these ports to be accessible -When the second stage of the check fails (either because there is no opened port in the -service, or service was not started properly before the timeout and the port is not -responding), it prints the warning: `*** WARNING: Service XYZ probably didn't start properly`. +When the second stage of the check fails, either because there is no opened port in the +service, or the service was not started properly before the timeout and the port is not +responding, it prints the warning: `*** WARNING: Service XYZ probably didn't start properly`. -In most cases it will affect the job, but there may be situations when job still succeeds -even if such warning was printed, e.g.: +In most cases it will affect the job, but there may be situations when the job +will still succeed even if that warning was printed. For example: -- service was started a little after the warning was raised, and the job is using it not - from the very beginning - in that case when the job (e.g. tests) needed to access the - service, it may be already there waiting for connections, -- service container is not providing any networking service, but doing something with job's - directory (all services have the job directory mounted as a volume under `/builds`) - in - that case the service will do its job, and since tje job is not trying to connect to it, - it doesn't fail. +- The service was started a little after the warning was raised, and the job is + not using the linked service from the very beginning. In that case, when the + job needed to access the service, it may have been already there waiting for + connections. +- The service container is not providing any networking service, but it's doing + something with the job's directory (all services have the job directory mounted + as a volume under `/builds`). In that case, the service will do its job, and + since the job is not trying to connect to it, it won't fail. ### What services are not for As it was mentioned before, this feature is designed to provide **network accessible** -services. A database is the easiest example of such service. +services. A database is the simplest example of such a service. -**Services feature is not designed to, and will not add any software from defined -service image to job's container.** +NOTE: **Note:** +The services feature is not designed to, and will not add any software from the +defined `services` image(s) to the job's container. -For example, such definition: +For example, if you have the following `services` defined in your job, the `php`, +`node` or `go` commands will **not** be available for your script, and thus +the job will fail: ```yaml job: @@ -133,39 +137,12 @@ job: - go version ``` -will not make `php`, `node` or `go` commands available for your script. So each of the -commands defined in `script:` section will fail. +If you need to have `php`, `node` and `go` available for your script, you should +either: -If you need to have `php`, `node` and `go` available for your script, you should either: - -- choose existing Docker image that contain all required tools, or -- choose the best existing Docker image that fits into your requirements and create - your own one, adding all missing tools on top of it. - -Looking at the example above, to make the job working as expected we should first -create an image, let's call it `my-php-node-go-image`, basing on Dockerfile like: - -```Dockerfile -FROM alpine:3.7 - -RUN command-to-install-php -RUN command-to-install-node -RUN command-to-install-golang -``` - -and then change the definition in `.gitlab-ci.yml` file to: - -```yaml -job: - image: my-php-node-go-image - script: - - php -v - - node -v - - go version -``` - -This time all required tools are available in job's container, so each of the -commands defined in `script:` section will eventualy succeed. +- choose an existing Docker image that contains all required tools, or +- create your own Docker image, which will have all the required tools included + and use that in your job ### Accessing the services -- cgit v1.2.1 From 88dffa45ce279729dc8c1cf0653b91d3bd9f7967 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Mon, 19 Mar 2018 10:04:23 +0100 Subject: Rename `package-qa` in docs In gitlab-org/gitlab-ce!17807 the `package-qa` job was renamed to `package-and-qa`. But it was not renamed in the docs. So this change fixes that. --- doc/development/testing_guide/end_to_end_tests.md | 2 +- qa/README.md | 2 +- qa/qa/page/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/development/testing_guide/end_to_end_tests.md b/doc/development/testing_guide/end_to_end_tests.md index 5b4f6511f04..d10a797a142 100644 --- a/doc/development/testing_guide/end_to_end_tests.md +++ b/doc/development/testing_guide/end_to_end_tests.md @@ -22,7 +22,7 @@ You can find these nightly pipelines at [GitLab QA pipelines page][gitlab-qa-pip It is possible to run end-to-end tests (eventually being run within a [GitLab QA pipeline][gitlab-qa-pipelines]) for a merge request by triggering -the `package-qa` manual action, that should be present in a merge request +the `package-and-qa` manual action, that should be present in a merge request widget. Manual action that starts end-to-end tests is also available in merge requests diff --git a/qa/README.md b/qa/README.md index 3a99a30d379..a4b4398645e 100644 --- a/qa/README.md +++ b/qa/README.md @@ -26,7 +26,7 @@ and corresponding views / partials / selectors in CE / EE. Whenever `qa:selectors` job fails in your merge request, you are supposed to fix [page objects](qa/page/README.md). You should also trigger end-to-end tests -using `package-qa` manual action, to test if everything works fine. +using `package-and-qa` manual action, to test if everything works fine. ## How can I use it? diff --git a/qa/qa/page/README.md b/qa/qa/page/README.md index 83710606d7c..d38223f690d 100644 --- a/qa/qa/page/README.md +++ b/qa/qa/page/README.md @@ -40,7 +40,7 @@ the time it would take to build packages and test everything. That is why when someone changes `t.text_field :login` to `t.text_field :username` in the _new session_ view we won't know about this change until our GitLab QA nightly pipeline fails, or until someone triggers -`package-qa` action in their merge request. +`package-and-qa` action in their merge request. Obviously such a change would break all tests. We call this problem a _fragile tests problem_. -- cgit v1.2.1 From afa002c10d04e4909186920409319d254fe641ee Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Mon, 19 Mar 2018 09:16:35 +0000 Subject: Updates svg dependency --- package.json | 2 +- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index deee668ae3b..c81020f631e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { - "@gitlab-org/gitlab-svgs": "^1.14.0", + "@gitlab-org/gitlab-svgs": "^1.16.0", "autosize": "^4.0.0", "axios": "^0.17.1", "babel-core": "^6.26.0", diff --git a/yarn.lock b/yarn.lock index 3cc5445c402..36683a2a480 100644 --- a/yarn.lock +++ b/yarn.lock @@ -54,9 +54,9 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" -"@gitlab-org/gitlab-svgs@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.14.0.tgz#b4a5cca3106f33224c5486cf674ba3b70cee727e" +"@gitlab-org/gitlab-svgs@^1.16.0": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.16.0.tgz#6c88a1bd9f5b3d3e5bf6a6d89d61724022185667" "@types/jquery@^2.0.40": version "2.0.48" -- cgit v1.2.1 From c242c77276ae588b0ea46e1d9f03b4880610489e Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Mon, 19 Mar 2018 09:47:50 +0000 Subject: Make issue boards height calculation clearer and remove magic numbers --- .../stylesheets/framework/contextual_sidebar.scss | 26 ++------ app/assets/stylesheets/framework/header.scss | 5 +- app/assets/stylesheets/framework/variables.scss | 69 ++++++++++++++-------- app/assets/stylesheets/pages/boards.scss | 41 +++++++++---- 4 files changed, 80 insertions(+), 61 deletions(-) diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 1acde98c3ae..e2d97d0298f 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -9,7 +9,8 @@ padding-left: $contextual-sidebar-width; } - .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { + .issues-bulk-update.right-sidebar.right-sidebar-expanded + .issuable-sidebar-header { padding: 10px 0 15px; } } @@ -61,7 +62,8 @@ } .nav-sidebar { - transition: width $sidebar-transition-duration, left $sidebar-transition-duration; + transition: width $sidebar-transition-duration, + left $sidebar-transition-duration; position: fixed; z-index: 400; width: $contextual-sidebar-width; @@ -75,7 +77,7 @@ &:not(.sidebar-collapsed-desktop) { @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) { box-shadow: inset -2px 0 0 $border-color, - 2px 1px 3px $dropdown-shadow-color; + 2px 1px 3px $dropdown-shadow-color; } } @@ -234,7 +236,7 @@ border-radius: 0 3px 3px 0; &::before { - content: ""; + content: ''; position: absolute; top: -30px; bottom: -30px; @@ -305,7 +307,6 @@ } } - // Collapsed nav .toggle-sidebar-button, @@ -454,18 +455,3 @@ z-index: 300; } } - - -// Make issue boards full-height now that sub-nav is gone - -.boards-list { - height: calc(100vh - #{$header-height}); - - @media (min-width: $screen-sm-min) { - height: calc(100vh - 180px); - } -} - -.with-performance-bar .boards-list { - height: calc(100vh - #{$header-height} - #{$performance-bar-height}); -} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 634593aefd0..bea58bade9d 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -184,7 +184,6 @@ } .container-fluid { - .navbar-nav { @media (max-width: $screen-xs-max) { display: -webkit-flex; @@ -337,7 +336,7 @@ .breadcrumbs { display: -webkit-flex; display: flex; - min-height: 48px; + min-height: $breadcrumb-min-height; color: $gl-text-color; } @@ -466,7 +465,7 @@ padding: 0 5px; line-height: 12px; border-radius: 7px; - box-shadow: 0 1px 0 rgba($gl-header-color, .2); + box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); &.issues-count { background-color: $green-500; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a5a8f6d2206..a81904d5338 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -5,9 +5,9 @@ $grid-size: 8px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 250px; -$sidebar-transition-duration: .3s; +$sidebar-transition-duration: 0.3s; $sidebar-breakpoint: 1024px; -$default-transition-duration: .15s; +$default-transition-duration: 0.15s; $contextual-sidebar-width: 220px; $contextual-sidebar-collapsed-width: 50px; @@ -129,7 +129,6 @@ $theme-green-800: #145d33; $theme-green-900: #0d4524; $theme-green-950: #072d16; - $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); $almost-black: #242424; @@ -163,7 +162,7 @@ $gl-text-color-secondary: #707070; $gl-text-color-tertiary: #949494; $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: rgba(255, 255, 255, 1); -$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85); $gl-text-color-disabled: #919191; $gl-text-green: $green-600; $gl-text-green-hover: $green-700; @@ -262,6 +261,7 @@ $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; $flash-height: 52px; $context-header-height: 60px; +$breadcrumb-min-height: 48px; /* * Common component specific colors @@ -296,7 +296,7 @@ $tanuki-yellow: #fca326; */ $gl-primary: $blue-500; $gl-success: $green-500; -$gl-success-focus: rgba($gl-success, .4); +$gl-success-focus: rgba($gl-success, 0.4); $gl-info: $blue-500; $gl-warning: $orange-500; $gl-danger: $red-500; @@ -331,8 +331,11 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts */ -$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', + 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; +$regular_font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; /* * Dropdowns @@ -343,16 +346,16 @@ $dropdown-max-height: 312px; $dropdown-vertical-offset: 4px; $dropdown-link-color: #555; $dropdown-link-hover-bg: $row-hover; -$dropdown-empty-row-bg: rgba(#000, .04); +$dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-border-color: $border-color; -$dropdown-shadow-color: rgba(#000, .1); -$dropdown-divider-color: rgba(#000, .1); +$dropdown-shadow-color: rgba(#000, 0.1); +$dropdown-divider-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; $dropdown-input-color: #555; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-border: $focus-border-color; -$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, .4); -$dropdown-loading-bg: rgba(#fff, .6); +$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, 0.4); +$dropdown-loading-bg: rgba(#fff, 0.6); $dropdown-chevron-size: 10px; $dropdown-toggle-active-border-color: darken($border-color, 14%); $dropdown-item-hover-bg: $gray-darker; @@ -367,9 +370,9 @@ $dropdown-hover-color: $blue-400; /* * Contextual Sidebar */ -$link-active-background: rgba(0, 0, 0, .04); -$link-hover-background: rgba(0, 0, 0, .06); -$inactive-badge-background: rgba(0, 0, 0, .08); +$link-active-background: rgba(0, 0, 0, 0.04); +$link-hover-background: rgba(0, 0, 0, 0.06); +$inactive-badge-background: rgba(0, 0, 0, 0.08); /* * Buttons @@ -397,14 +400,14 @@ $status-icon-margin: $gl-btn-padding; /* * Award emoji */ -$award-emoji-menu-shadow: rgba(0, 0, 0, .175); +$award-emoji-menu-shadow: rgba(0, 0, 0, 0.175); $award-emoji-positive-add-bg: #fed159; $award-emoji-positive-add-lines: #bb9c13; /* * Search Box */ -$search-input-border-color: rgba($blue-400, .8); +$search-input-border-color: rgba($blue-400, 0.8); $search-input-focus-shadow-color: $dropdown-input-focus-shadow; $search-input-width: 220px; $location-badge-active-bg: $blue-500; @@ -429,7 +432,7 @@ $zen-control-color: #555; * Calendar */ $calendar-hover-bg: #ecf3fe; -$calendar-border-color: rgba(#000, .1); +$calendar-border-color: rgba(#000, 0.1); $calendar-user-contrib-text: #959494; /* @@ -452,6 +455,17 @@ $ci-skipped-color: #888; */ $issue-boards-font-size: 14px; $issue-boards-card-shadow: rgba(186, 186, 186, 0.5); +/* + The following heights are used in boards.scss and are used for calculation of the board height. + They probably should be derived in a smarter way. +*/ +$issue-boards-filter-height: 68px; +$issue-boards-breadcrumbs-height-xs: 63px; +$issue-board-list-difference-xs: $header-height + + $issue-boards-breadcrumbs-height-xs; +$issue-board-list-difference-sm: $header-height + $breadcrumb-min-height; +$issue-board-list-difference-md: $issue-board-list-difference-sm + + $issue-boards-filter-height; /* * Avatar @@ -567,14 +581,14 @@ $label-padding: 7px; $label-padding-modal: 10px; $label-gray-bg: #f8fafc; $label-inverse-bg: #333; -$label-remove-border: rgba(0, 0, 0, .1); +$label-remove-border: rgba(0, 0, 0, 0.1); $label-border-radius: 100px; /* * Animation */ $fade-in-duration: 200ms; -$fade-mask-transition-duration: .1s; +$fade-mask-transition-duration: 0.1s; $fade-mask-transition-curve: ease-in-out; /* @@ -642,7 +656,6 @@ $stat-graph-selection-stroke: #333; $select2-drop-shadow1: rgba(76, 86, 103, 0.247059); $select2-drop-shadow2: rgba(31, 37, 50, 0.317647); - /* * Todo */ @@ -679,7 +692,6 @@ CI variable lists */ $ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding}); - /* Filtered Search */ @@ -706,7 +718,14 @@ Repo editor */ $repo-editor-grey: #f6f7f9; $repo-editor-grey-darker: #e9ebee; -$repo-editor-linear-gradient: linear-gradient(to right, $repo-editor-grey 0%, $repo-editor-grey-darker, 20%, $repo-editor-grey 40%, $repo-editor-grey 100%); +$repo-editor-linear-gradient: linear-gradient( + to right, + $repo-editor-grey 0%, + $repo-editor-grey-darker, + 20%, + $repo-editor-grey 40%, + $repo-editor-grey 100% +); /* Performance Bar @@ -717,8 +736,8 @@ $perf-bar-staging: #291430; $perf-bar-development: #4c1210; $perf-bar-bucket-bg: #111; $perf-bar-bucket-color: #ccc; -$perf-bar-bucket-box-shadow-from: rgba($white-light, .2); -$perf-bar-bucket-box-shadow-to: rgba($black, .25); +$perf-bar-bucket-box-shadow-from: rgba($white-light, 0.2); +$perf-bar-bucket-box-shadow-to: rgba($black, 0.25); /* Issuable warning diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 2803144ef1d..c03d4c2eebf 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -1,4 +1,4 @@ -@import "./issues/issue_count_badge"; +@import './issues/issue_count_badge'; [v-cloak] { display: none; @@ -72,22 +72,37 @@ } .boards-list { - height: calc(100vh - 105px); + height: calc(100vh - #{$issue-board-list-difference-xs}); width: 100%; - padding-top: 25px; - padding-bottom: 25px; - padding-right: ($gl-padding / 2); - padding-left: ($gl-padding / 2); + padding: $gl-padding ($gl-padding / 2); overflow-x: scroll; white-space: nowrap; + min-height: 200px; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - height: calc(100vh - 90px); + height: calc(100vh - #{$issue-board-list-difference-sm}); } @media (min-width: $screen-md-min) { - height: calc(100vh - 160px); - min-height: 475px; + height: calc(100vh - #{$issue-board-list-difference-md}); + } + + .with-performance-bar & { + height: calc( + 100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height} + ); + + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + height: calc( + 100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height} + ); + } + + @media (min-width: $screen-md-min) { + height: calc( + 100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height} + ); + } } } @@ -454,7 +469,7 @@ &.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-leave-active { transition: width $sidebar-transition-duration, - padding $sidebar-transition-duration; + padding $sidebar-transition-duration; } &.boards-sidebar-slide-enter, @@ -473,7 +488,7 @@ right: 0; bottom: 0; left: 0; - background-color: rgba($black, .3); + background-color: rgba($black, 0.3); z-index: 9999; } @@ -490,7 +505,7 @@ padding: 25px 15px 0; background-color: $white-light; border-radius: $border-radius-default; - box-shadow: 0 2px 12px rgba($black, .5); + box-shadow: 0 2px 12px rgba($black, 0.5); .empty-state { display: -webkit-flex; @@ -568,7 +583,7 @@ .card { border: 1px solid $border-gray-dark; - box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, .3); + box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3); cursor: pointer; } } -- cgit v1.2.1 From 89a1a90e375a58ac4eaa734d40aaa132a0c0fc40 Mon Sep 17 00:00:00 2001 From: Abubakar Ango Date: Mon, 19 Mar 2018 10:07:42 +0000 Subject: Added Video explaining Cloud native GitLab Chart --- doc/install/kubernetes/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md index cd889e74487..3a1707a54a1 100644 --- a/doc/install/kubernetes/index.md +++ b/doc/install/kubernetes/index.md @@ -37,7 +37,7 @@ By offering individual containers and charts, we will be able to provide a numbe This is a large project and will be worked on over the span of multiple releases. For the most up-to-date status and release information, please see our [tracking issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420). We are planning to launch this chart in beta by the end of 2017. -Learn more about the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). +Learn more about the [cloud native GitLab chart here ](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) and [here [Video]](https://youtu.be/Z6jWR8Z8dv8). ## Other Charts -- cgit v1.2.1 From 07e7b15f80815ac11e4625c7678f76c599c76a6b Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 6 Mar 2018 00:36:34 +0200 Subject: Move ShaMismatch vue component --- .../components/states/mr_widget_sha_mismatch.js | 18 ---------------- .../components/states/sha_mismatch.vue | 25 ++++++++++++++++++++++ .../vue_merge_request_widget/dependencies.js | 2 +- .../vue_merge_request_widget/mr_widget_options.js | 4 ++-- .../vue_merge_request_widget/stores/state_maps.js | 2 +- ...r-move-mr-widget-sha-mismatch-vue-component.yml | 5 +++++ .../states/mr_widget_sha_mismatch_spec.js | 9 ++++---- 7 files changed, 39 insertions(+), 26 deletions(-) delete mode 100644 app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js create mode 100644 app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue create mode 100644 changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js deleted file mode 100644 index 142ddf477f1..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js +++ /dev/null @@ -1,18 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetSHAMismatch', - components: { - statusIcon, - }, - template: ` -
- -
- - The source branch HEAD has recently changed. Please reload the page and review the changes before merging - -
-
- `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue new file mode 100644 index 00000000000..04100871a94 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -0,0 +1,25 @@ + + + diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 021c2237661..ed15fc6ab0f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -28,7 +28,7 @@ export { default as NothingToMergeState } from './components/states/nothing_to_m export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue'; export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue'; export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; -export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; +export { default as ShaMismatchState } from './components/states/sha_mismatch.vue'; export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue'; export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 169adfe0a1d..0be5d9e5a55 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -19,7 +19,7 @@ import { MissingBranchState, NotAllowedState, ReadyToMergeState, - SHAMismatchState, + ShaMismatchState, UnresolvedDiscussionsState, PipelineBlockedState, PipelineFailedState, @@ -227,7 +227,7 @@ export default { 'mr-widget-not-allowed': NotAllowedState, 'mr-widget-missing-branch': MissingBranchState, 'mr-widget-ready-to-merge': ReadyToMergeState, - 'mr-widget-sha-mismatch': SHAMismatchState, + 'mr-widget-sha-mismatch': ShaMismatchState, 'mr-widget-squash-before-merge': SquashBeforeMerge, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 483ad52b8cc..e080ce5c229 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -16,7 +16,7 @@ const stateToComponentMap = { mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', - shaMismatch: 'mr-widget-sha-mismatch', + shaMismatch: 'sha-mismatch', rebase: 'mr-widget-rebase', }; diff --git a/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml b/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml new file mode 100644 index 00000000000..ac41fe23d3d --- /dev/null +++ b/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move ShaMismatch vue component +merge_request: 17546 +author: George Tsiolis +type: performance diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js index 4c67504b642..25684861724 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -1,16 +1,17 @@ import Vue from 'vue'; -import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch'; +import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue'; -describe('MRWidgetSHAMismatch', () => { +describe('ShaMismatch', () => { describe('template', () => { - const Component = Vue.extend(shaMismatchComponent); + const Component = Vue.extend(ShaMismatch); const vm = new Component({ el: document.createElement('div'), }); it('should have correct elements', () => { expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); - expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging'); + expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed.'); + expect(vm.$el.innerText).toContain('Please reload the page and review the changes before merging.'); }); }); }); -- cgit v1.2.1 From d5079b626c3b07d97d9399594d23e5474e6359de Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 19 Mar 2018 11:53:35 +0000 Subject: Resolve "GitLab Community Edition 10.5.3 shows plural for 1 item" --- app/views/projects/diffs/_stats.html.haml | 4 +- changelogs/unreleased/44022-singular-1-diff.yml | 5 ++ spec/views/projects/diffs/_stats.html.haml_spec.rb | 56 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/44022-singular-1-diff.yml create mode 100644 spec/views/projects/diffs/_stats.html.haml_spec.rb diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index b082ad0ef0e..6fd6018dea3 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -7,9 +7,9 @@ = icon("caret-down", class: "prepend-left-5") %span.diff-stats-additions-deletions-expanded#diff-stats with - %strong.cgreen #{sum_added_lines} additions + %strong.cgreen= pluralize(sum_added_lines, 'addition') and - %strong.cred #{sum_removed_lines} deletions + %strong.cred= pluralize(sum_removed_lines, 'deletion') .diff-stats-additions-deletions-collapsed.pull-right.hidden-xs.hidden-sm{ "aria-hidden": "true", "aria-describedby": "diff-stats" } %strong.cgreen< +#{sum_added_lines} diff --git a/changelogs/unreleased/44022-singular-1-diff.yml b/changelogs/unreleased/44022-singular-1-diff.yml new file mode 100644 index 00000000000..f4942925a73 --- /dev/null +++ b/changelogs/unreleased/44022-singular-1-diff.yml @@ -0,0 +1,5 @@ +--- +title: Use singular in the diff stats if only one line has been changed +merge_request: 17697 +author: Jan Beckmann +type: fixed diff --git a/spec/views/projects/diffs/_stats.html.haml_spec.rb b/spec/views/projects/diffs/_stats.html.haml_spec.rb new file mode 100644 index 00000000000..c7d2f85747c --- /dev/null +++ b/spec/views/projects/diffs/_stats.html.haml_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'projects/diffs/_stats.html.haml' do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') } + + def render_view + render partial: "projects/diffs/stats", locals: { diff_files: commit.diffs.diff_files } + end + + context 'when the commit contains several changes' do + it 'uses plural for additions' do + render_view + + expect(rendered).to have_text('additions') + end + + it 'uses plural for deletions' do + render_view + end + end + + context 'when the commit contains no addition and no deletions' do + let(:commit) { project.commit('4cd80ccab63c82b4bad16faa5193fbd2aa06df40') } + + it 'uses plural for additions' do + render_view + + expect(rendered).to have_text('additions') + end + + it 'uses plural for deletions' do + render_view + + expect(rendered).to have_text('deletions') + end + end + + context 'when the commit contains exactly one addition and one deletion' do + let(:commit) { project.commit('08f22f255f082689c0d7d39d19205085311542bc') } + + it 'uses singular for additions' do + render_view + + expect(rendered).to have_text('addition') + expect(rendered).not_to have_text('additions') + end + + it 'uses singular for deletions' do + render_view + + expect(rendered).to have_text('deletion') + expect(rendered).not_to have_text('deletions') + end + end +end -- cgit v1.2.1 From 30442f67babbacd97b9a838dbf5420011e9f741f Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Mon, 19 Mar 2018 13:42:30 +0000 Subject: Remove oj as we're not using it --- Gemfile | 3 --- Gemfile.lock | 2 -- 2 files changed, 5 deletions(-) diff --git a/Gemfile b/Gemfile index dcd5f5ee049..2a68053f7f3 100644 --- a/Gemfile +++ b/Gemfile @@ -235,9 +235,6 @@ gem 'mousetrap-rails', '~> 1.4.6' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.5' -# Faster JSON -gem 'oj', '~> 2.17.4' - # Faster blank gem 'fast_blank' diff --git a/Gemfile.lock b/Gemfile.lock index 618fbf4b3e7..a75e3866b26 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -523,7 +523,6 @@ GEM rack (>= 1.2, < 3) octokit (4.8.0) sawyer (~> 0.8.0, >= 0.5.3) - oj (2.17.5) omniauth (1.4.3) hashie (>= 1.2, < 4) rack (>= 1.6.2, < 3) @@ -1106,7 +1105,6 @@ DEPENDENCIES nokogiri (~> 1.8.2) oauth2 (~> 1.4) octokit (~> 4.8) - oj (~> 2.17.4) omniauth (~> 1.4.2) omniauth-auth0 (~> 1.4.1) omniauth-authentiq (~> 0.3.1) -- cgit v1.2.1 From 301c4ef9502e02799e64937b35fb47b2e15827b3 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Mon, 19 Mar 2018 22:48:09 +0900 Subject: Use update_column than write_attribute and save --- app/models/ci/build.rb | 3 +-- spec/lib/gitlab/ci/trace_spec.rb | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index c1da2081465..e68c1012199 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -328,8 +328,7 @@ module Ci end def erase_old_trace! - write_attribute(:trace, nil) - save + update_column(:trace, nil) end def needs_touch? diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 448c6fb57dd..3a9371ed2e8 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -510,6 +510,28 @@ describe Gitlab::Ci::Trace do it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid end + + context 'when there is a validation error on Ci::Build' do + before do + allow_any_instance_of(Ci::Build).to receive(:save).and_return(false) + allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages) + .and_return(%w[Error Error]) + end + + context "when erase old trace with 'save'" do + before do + build.send(:write_attribute, :trace, nil) + build.save + end + + it 'old trace is not deleted' do + build.reload + expect(build.trace.raw).to eq(trace_content) + end + end + + it_behaves_like 'archive trace in database' + end end end -- cgit v1.2.1 From 6cfbb4459e35d88e88aa5f463a984f40db0e6bc9 Mon Sep 17 00:00:00 2001 From: Simon Knox Date: Mon, 19 Mar 2018 14:37:48 +0000 Subject: Resolve "Notes karma specs failing" --- app/assets/javascripts/notes.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 659ae575219..2afa4e4c1bf 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -105,6 +105,9 @@ export default class Notes { this.basePollingInterval = 15000; this.maxPollingSteps = 4; + this.$wrapperEl = hasVueMRDiscussionsCookie() + ? $(document).find('.diffs') + : $(document); this.cleanBinding(); this.addBinding(); this.setPollingInterval(); @@ -138,10 +141,6 @@ export default class Notes { } addBinding() { - this.$wrapperEl = hasVueMRDiscussionsCookie() - ? $(document).find('.diffs') - : $(document); - // Edit note link this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit); @@ -226,14 +225,9 @@ export default class Notes { $(window).on('hashchange', this.onHashChange); this.boundGetContent = this.getContent.bind(this); document.addEventListener('refreshLegacyNotes', this.boundGetContent); - this.eventsBound = true; } cleanBinding() { - if (!this.eventsBound) { - return; - } - this.$wrapperEl.off('click', '.js-note-edit'); this.$wrapperEl.off('click', '.note-edit-cancel'); this.$wrapperEl.off('click', '.js-note-delete'); -- cgit v1.2.1 From 48f0eff37ab74ba4409dd28c5003898e2272a010 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 18 Mar 2018 15:13:17 -0700 Subject: Remove N+1 queries in /admin/projects page --- app/finders/admin/projects_finder.rb | 1 + spec/controllers/admin/projects_controller_spec.rb | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index 5c507fe8d50..2c8f21c2400 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -16,6 +16,7 @@ class Admin::ProjectsFinder items = by_archived(items) items = by_personal(items) items = by_name(items) + items = items.includes(namespace: [:owner]) sort(items).page(params[:page]) end diff --git a/spec/controllers/admin/projects_controller_spec.rb b/spec/controllers/admin/projects_controller_spec.rb index d5a3c250f31..cc200b9fed9 100644 --- a/spec/controllers/admin/projects_controller_spec.rb +++ b/spec/controllers/admin/projects_controller_spec.rb @@ -31,5 +31,15 @@ describe Admin::ProjectsController do expect(response.body).not_to match(pending_delete_project.name) expect(response.body).to match(project.name) end + + it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do + get :index + + control_count = ActiveRecord::QueryRecorder.new { get :index }.count + + create(:project) + + expect { get :index }.not_to exceed_query_limit(control_count) + end end end -- cgit v1.2.1 From 46b2933c8e0e5c6bc2571a16b5767445a7c74a94 Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Tue, 13 Mar 2018 13:51:17 +0100 Subject: Update licensee 8.7.0 -> 8.9 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index bbb97cd696b..ecbcb8757ed 100644 --- a/Gemfile +++ b/Gemfile @@ -221,7 +221,7 @@ gem 'babosa', '~> 1.0.2' gem 'loofah', '~> 2.0.3' # Working with license -gem 'licensee', '~> 8.7.0' +gem 'licensee', '~> 8.9' # Protect against bruteforcing gem 'rack-attack', '~> 4.4.1' diff --git a/Gemfile.lock b/Gemfile.lock index 817772dfdcb..1b1662c03be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -474,7 +474,7 @@ GEM toml (= 0.1.2) with_env (> 1.0) xml-simple - licensee (8.7.0) + licensee (8.9.2) rugged (~> 0.24) little-plugger (1.1.4) locale (2.1.2) @@ -1093,7 +1093,7 @@ DEPENDENCIES kubeclient (~> 3.0) letter_opener_web (~> 1.3.0) license_finder (~> 3.1) - licensee (~> 8.7.0) + licensee (~> 8.9) lograge (~> 0.5) loofah (~> 2.0.3) mail_room (~> 0.9.1) -- cgit v1.2.1 From 871fd1096a7a2f4e09550e36d887cfae12998fee Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Mon, 19 Mar 2018 17:36:32 +0100 Subject: Document static IP and DNS configuration for Kubernetes --- doc/user/project/clusters/index.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 8da4fd8a1f5..bd9bcfadb99 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -167,16 +167,14 @@ external IP address with the following procedure. It can be deployed using the In order to publish your web application, you first need to find the external IP address associated to your load balancer. -### GitLab can automatically determine the IP address for you +### Let GitLab fetch the IP address > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17052) in GitLab 10.6. -If you installed the Ingress [via the -**Applications**](#installing-applications) you should see the Ingress IP address on -this same page within a few minutes. There may be some -instances in which GitLab cannot determine the IP address of your ingress -application in which case you can read on for other ways of manually -determining the IP address. +If you installed the Ingress [via the **Applications**](#installing-applications), +you should see the Ingress IP address on this same page within a few minutes. +If you don't see this, GitLab might not be able to determine the IP address of +your ingress application in which case you should manually determine it. ### Manually determining the IP address @@ -206,6 +204,24 @@ The output is the external IP address of your cluster. This information can then be used to set up DNS entries and forwarding rules that allow external access to your deployed applications. +### Using a static IP + +By default, an ephemeral external IP address is associated to the cluster's load +balancer. If you associate the ephemeral IP with your DNS and the IP changes, +your apps will not be able to be reached, and you'd have to change the DNS +record again. In order to avoid that, you should change it into a static +reserved IP. + +[Read how to promote an ephemeral external IP address in GKE.](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip) + +### Pointing your DNS at the cluster IP + +Once you've set up the static IP, you should associate it to a [wildcard DNS +record](https://en.wikipedia.org/wiki/Wildcard_DNS_record), in order to be able +to reach your apps. This heavily depends on your domain provider, but in case +you aren't sure, just create an A record with a wildcard host like +`*.example.com.`. + ## Setting the environment scope NOTE: **Note:** -- cgit v1.2.1 From 3c1a93078cb7acd1d8b1a686b3f6fbecc1188373 Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 18 Mar 2018 03:01:18 +0900 Subject: Clean up selectors in framework/header.scss --- app/assets/stylesheets/framework/header.scss | 108 ++++++++++----------- .../unreleased/44383-cleanup-framework-header.yml | 5 + 2 files changed, 57 insertions(+), 56 deletions(-) create mode 100644 changelogs/unreleased/44383-cleanup-framework-header.yml diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index bea58bade9d..0136af76a13 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,60 +1,24 @@ .navbar-gitlab { - &.navbar-gitlab { - padding: 0 16px; - z-index: 1000; - margin-bottom: 0; - min-height: $header-height; - border: 0; - border-bottom: 1px solid $border-color; - position: fixed; - top: 0; - left: 0; - right: 0; - border-radius: 0; - - .logo-text { - line-height: initial; - - svg { - width: 55px; - height: 14px; - margin: 0; - fill: $white-light; - } - } - - .container-fluid { - padding: 0; - - .user-counter { - svg { - margin-right: 3px; - } - } - - .navbar-toggle { - right: -10px; - border-radius: 0; - min-width: 45px; - padding: 0; - margin-right: -7px; - font-size: 14px; - text-align: center; - color: currentColor; - - &:hover, - &:focus, - &.active { - color: currentColor; - background-color: transparent; - } - - .more-icon, - .close-icon { - fill: $white-light; - margin: auto; - } - } + padding: 0 16px; + z-index: 1000; + margin-bottom: 0; + min-height: $header-height; + border: 0; + border-bottom: 1px solid $border-color; + position: fixed; + top: 0; + left: 0; + right: 0; + border-radius: 0; + + .logo-text { + line-height: initial; + + svg { + width: 55px; + height: 14px; + margin: 0; + fill: $white-light; } } @@ -184,6 +148,38 @@ } .container-fluid { + padding: 0; + + .user-counter { + svg { + margin-right: 3px; + } + } + + .navbar-toggle { + right: -10px; + border-radius: 0; + min-width: 45px; + padding: 0; + margin-right: -7px; + font-size: 14px; + text-align: center; + color: currentColor; + + &:hover, + &:focus, + &.active { + color: currentColor; + background-color: transparent; + } + + .more-icon, + .close-icon { + fill: $white-light; + margin: auto; + } + } + .navbar-nav { @media (max-width: $screen-xs-max) { display: -webkit-flex; diff --git a/changelogs/unreleased/44383-cleanup-framework-header.yml b/changelogs/unreleased/44383-cleanup-framework-header.yml new file mode 100644 index 00000000000..ef9be9f48de --- /dev/null +++ b/changelogs/unreleased/44383-cleanup-framework-header.yml @@ -0,0 +1,5 @@ +--- +title: Clean up selectors in framework/header.scss +merge_request: 17822 +author: Takuya Noguchi +type: other -- cgit v1.2.1 From e118487c2a9f86ffd6c23e1f709662314b1284b6 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 19 Mar 2018 12:53:20 -0500 Subject: Remove `www.` from `twitter_url` helper Twitter redirects `www.twitter.com` to `twitter.com` Also removes unnecessary regex escapes, just 'cause. --- app/helpers/application_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index af9c8bf1bd3..3ddf8eb3369 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -300,7 +300,7 @@ module ApplicationHelper def linkedin_url(user) name = user.linkedin - if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/} + if name =~ %r{\Ahttps?://(www\.)?linkedin\.com/in/} name else "https://www.linkedin.com/in/#{name}" @@ -309,10 +309,10 @@ module ApplicationHelper def twitter_url(user) name = user.twitter - if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/} + if name =~ %r{\Ahttps?://(www\.)?twitter\.com/} name else - "https://www.twitter.com/#{name}" + "https://twitter.com/#{name}" end end -- cgit v1.2.1 From d17d3ec7f7d25af9091125bf6eac87ab1d30f938 Mon Sep 17 00:00:00 2001 From: Jasper Maes Date: Thu, 15 Mar 2018 17:11:20 +0100 Subject: Split repository search result on \n instead of $ to prevent the items of the array to start with a newline. Remove the strip from parsing the search result to keep result endlines. --- changelogs/unreleased/44280-fix-code-search.yml | 5 +++++ lib/gitlab/git/repository.rb | 2 +- lib/gitlab/project_search_results.rb | 2 +- spec/lib/gitlab/project_search_results_spec.rb | 22 +++++++++++++++++----- 4 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/44280-fix-code-search.yml diff --git a/changelogs/unreleased/44280-fix-code-search.yml b/changelogs/unreleased/44280-fix-code-search.yml new file mode 100644 index 00000000000..07f3abb224c --- /dev/null +++ b/changelogs/unreleased/44280-fix-code-search.yml @@ -0,0 +1,5 @@ +--- +title: Fix search results stripping last endline when parsing the results +merge_request: 17777 +author: Jasper Maes +type: fixed diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index fbc93542619..0853ea69016 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1389,7 +1389,7 @@ module Gitlab offset = 2 args = %W(grep -i -I -n -z --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref}) - run_git(args).first.scrub.split(/^--$/) + run_git(args).first.scrub.split(/^--\n/) end def can_be_merged?(source_sha, target_branch) diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 29277ec6481..390efda326a 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -58,7 +58,7 @@ module Gitlab data = "" startline = 0 - result.strip.each_line.each_with_index do |line, index| + result.each_line.each_with_index do |line, index| prefix ||= line.match(/^(?[^:]*):(?.*)\x00(?\d+)\x00/)&.tap do |matches| ref = matches[:ref] filename = matches[:filename] diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 57905a74e92..8351b967133 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -83,19 +83,19 @@ describe Gitlab::ProjectSearchResults do end context 'when the matching filename contains a colon' do - let(:search_result) { "\nmaster:testdata/project::function1.yaml\x001\x00---\n" } + let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } it 'returns a valid FoundBlob' do expect(subject.filename).to eq('testdata/project::function1.yaml') expect(subject.basename).to eq('testdata/project::function1') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq('---') + expect(subject.data).to eq("---\n") end end context 'when the matching content contains a number surrounded by colons' do - let(:search_result) { "\nmaster:testdata/foo.txt\x001\x00blah:9:blah" } + let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } it 'returns a valid FoundBlob' do expect(subject.filename).to eq('testdata/foo.txt') @@ -106,6 +106,18 @@ describe Gitlab::ProjectSearchResults do end end + context 'when the search result ends with an empty line' do + let(:results) { project.repository.search_files_by_content('Role models', 'master') } + + it 'returns a valid FoundBlob that ends with an empty line' do + expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') + expect(subject.basename).to eq('files/markdown/ruby-style-guide') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("# Prelude\n\n> Role models are important.
\n> -- Officer Alex J. Murphy / RoboCop\n\n") + end + end + context 'when the search returns non-ASCII data' do context 'with UTF-8' do let(:results) { project.repository.search_files_by_content('файл', 'master') } @@ -115,7 +127,7 @@ describe Gitlab::ProjectSearchResults do expect(subject.basename).to eq('encoding/russian') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq('Хороший файл') + expect(subject.data).to eq("Хороший файл\n") end end @@ -139,7 +151,7 @@ describe Gitlab::ProjectSearchResults do expect(subject.basename).to eq('encoding/iso8859') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq("Äü\n\nfoo") + expect(subject.data).to eq("Äü\n\nfoo\n") end end end -- cgit v1.2.1 From 4cb3b71d9dcb447883abbc2086834bb36e4e72de Mon Sep 17 00:00:00 2001 From: Mario de la Ossa Date: Thu, 15 Mar 2018 19:05:44 -0600 Subject: Always notify new mentions even if explicitly unsubscribed --- app/models/notification_recipient.rb | 3 ++- .../unreleased/43933-always-notify-mentions.yml | 6 ++++++ spec/services/notification_service_spec.rb | 21 ++++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 changelogs/unreleased/43933-always-notify-mentions.yml diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index fd70e920c7e..e95655e19f8 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -35,7 +35,8 @@ class NotificationRecipient # check this last because it's expensive # nobody should receive notifications if they've specifically unsubscribed - return false if unsubscribed? + # except if they were mentioned. + return false if @type != :mention && unsubscribed? true end diff --git a/changelogs/unreleased/43933-always-notify-mentions.yml b/changelogs/unreleased/43933-always-notify-mentions.yml new file mode 100644 index 00000000000..7b494d38541 --- /dev/null +++ b/changelogs/unreleased/43933-always-notify-mentions.yml @@ -0,0 +1,6 @@ +--- +title: Send @mention notifications even if a user has explicitly unsubscribed from + item +merge_request: +author: +type: added diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 62fdf870090..3943148f0db 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -34,6 +34,12 @@ describe NotificationService, :mailer do should_not_email_anyone end + it 'emails new mentions despite being unsubscribed' do + send_notifications(@unsubscribed_mentioned) + + should_only_email(@unsubscribed_mentioned) + end + it 'sends the proper notification reason header' do send_notifications(@u_watcher) should_only_email(@u_watcher) @@ -122,7 +128,7 @@ describe NotificationService, :mailer do let(:project) { create(:project, :private) } let(:issue) { create(:issue, project: project, assignees: [assignee]) } let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } - let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } + let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } before do build_team(note.project) @@ -150,7 +156,7 @@ describe NotificationService, :mailer do add_users_with_subscription(note.project, issue) reset_delivered_emails! - expect(SentNotification).to receive(:record).with(issue, any_args).exactly(9).times + expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times notification.new_note(note) @@ -163,6 +169,7 @@ describe NotificationService, :mailer do should_email(@watcher_and_subscriber) should_email(@subscribed_participant) should_email(@u_custom_off) + should_email(@unsubscribed_mentioned) should_not_email(@u_guest_custom) should_not_email(@u_guest_watcher) should_not_email(note.author) @@ -279,6 +286,7 @@ describe NotificationService, :mailer do before do build_team(note.project) note.project.add_master(note.author) + add_users_with_subscription(note.project, issue) reset_delivered_emails! end @@ -286,6 +294,9 @@ describe NotificationService, :mailer do it 'notifies the team members' do notification.new_note(note) + # Make sure @unsubscribed_mentioned is part of the team + expect(note.project.team.members).to include(@unsubscribed_mentioned) + # Notify all team members note.project.team.members.each do |member| # User with disabled notification should not be notified @@ -486,7 +497,7 @@ describe NotificationService, :mailer do let(:group) { create(:group) } let(:project) { create(:project, :public, namespace: group) } let(:another_project) { create(:project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' } before do build_team(issue.project) @@ -510,6 +521,7 @@ describe NotificationService, :mailer do should_email(@u_participant_mentioned) should_email(@g_global_watcher) should_email(@g_watcher) + should_email(@unsubscribed_mentioned) should_not_email(@u_mentioned) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -1823,6 +1835,7 @@ describe NotificationService, :mailer do def add_users_with_subscription(project, issuable) @subscriber = create :user @unsubscriber = create :user + @unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned' @subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating) @watcher_and_subscriber = create_global_setting_for(create(:user), :watch) @@ -1830,7 +1843,9 @@ describe NotificationService, :mailer do project.add_master(@subscriber) project.add_master(@unsubscriber) project.add_master(@watcher_and_subscriber) + project.add_master(@unsubscribed_mentioned) + issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false) issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true) issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true) issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false) -- cgit v1.2.1 From 2370ff85fe6fec3ca94d073c7460edf2c6123b59 Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Wed, 14 Mar 2018 16:49:21 +0100 Subject: Optional '/-/' delimiter for search API '/-/' delimiter is used only in UI, in API we don't use it for other endpoints. To align search endpoints with the rest of API endpoints, this patch makes '/-/' optional for existing endpoints (to keep backward compatibility). Documentation is updated to prefer paths without '/-/'. --- changelogs/unreleased/optional-api-delimiter.yml | 5 +++ doc/api/search.md | 26 ++++++------- lib/api/search.rb | 4 +- spec/requests/api/search_spec.rb | 48 ++++++++++++------------ 4 files changed, 44 insertions(+), 39 deletions(-) create mode 100644 changelogs/unreleased/optional-api-delimiter.yml diff --git a/changelogs/unreleased/optional-api-delimiter.yml b/changelogs/unreleased/optional-api-delimiter.yml new file mode 100644 index 00000000000..0bcd0787306 --- /dev/null +++ b/changelogs/unreleased/optional-api-delimiter.yml @@ -0,0 +1,5 @@ +--- +title: Make /-/ delimiter optional for search endpoints +merge_request: +author: +type: changed diff --git a/doc/api/search.md b/doc/api/search.md index d441b556186..107ddaffa6a 100644 --- a/doc/api/search.md +++ b/doc/api/search.md @@ -289,7 +289,7 @@ Search within the specified group. If a user is not a member of a group and the group is private, a `GET` request on that group will result to a `404` status code. ``` -GET /groups/:id/-/search +GET /groups/:id/search ``` | Attribute | Type | Required | Description | @@ -305,7 +305,7 @@ The response depends on the requested scope. ### Scope: projects ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=projects&search=flight +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=projects&search=flight ``` Example response: @@ -336,7 +336,7 @@ Example response: ### Scope: issues ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=issues&search=file +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=issues&search=file ``` Example response: @@ -401,7 +401,7 @@ Example response: ### Scope: merge_requests ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=merge_requests&search=file +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=merge_requests&search=file ``` Example response: @@ -478,7 +478,7 @@ Example response: ### Scope: milestones ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=milestones&search=release +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=milestones&search=release ``` Example response: @@ -507,7 +507,7 @@ Search within the specified project. If a user is not a member of a project and the project is private, a `GET` request on that project will result to a `404` status code. ``` -GET /projects/:id/-/search +GET /projects/:id/search ``` | Attribute | Type | Required | Description | @@ -524,7 +524,7 @@ The response depends on the requested scope. ### Scope: issues ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=issues&search=file +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/search?scope=issues&search=file ``` Example response: @@ -589,7 +589,7 @@ Example response: ### Scope: merge_requests ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=merge_requests&search=file +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=merge_requests&search=file ``` Example response: @@ -666,7 +666,7 @@ Example response: ### Scope: milestones ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=milestones&search=release +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/search?scope=milestones&search=release ``` Example response: @@ -691,7 +691,7 @@ Example response: ### Scope: notes ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=notes&search=maxime +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=notes&search=maxime ``` Example response: @@ -723,7 +723,7 @@ Example response: ### Scope: wiki_blobs ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=wiki_blobs&search=bye +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=wiki_blobs&search=bye ``` Example response: @@ -746,7 +746,7 @@ Example response: ### Scope: commits ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=commits&search=bye +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=commits&search=bye ``` Example response: @@ -777,7 +777,7 @@ Example response: ### Scope: blobs ```bash -curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=blobs&search=installation +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=blobs&search=installation ``` Example response: diff --git a/lib/api/search.rb b/lib/api/search.rb index 3556ad98c52..5d9ec617cb7 100644 --- a/lib/api/search.rb +++ b/lib/api/search.rb @@ -84,7 +84,7 @@ module API values: %w(projects issues merge_requests milestones) use :pagination end - get ':id/-/search' do + get ':id/(-/)search' do present search(group_id: user_group.id), with: entity end end @@ -103,7 +103,7 @@ module API values: %w(issues merge_requests milestones notes wiki_blobs commits blobs) use :pagination end - get ':id/-/search' do + get ':id/(-/)search' do present search(project_id: user_project.id), with: entity end end diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index 9052a18c60b..f8d5258a8d9 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -99,10 +99,10 @@ describe API::Search do end end - describe "GET /groups/:id/-/search" do + describe "GET /groups/:id/search" do context 'when user is not authenticated' do it 'returns 401 error' do - get api("/groups/#{group.id}/-/search"), scope: 'projects', search: 'awesome' + get api("/groups/#{group.id}/search"), scope: 'projects', search: 'awesome' expect(response).to have_gitlab_http_status(401) end @@ -110,7 +110,7 @@ describe API::Search do context 'when scope is not supported' do it 'returns 400 error' do - get api("/groups/#{group.id}/-/search", user), scope: 'unsupported', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'unsupported', search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -118,7 +118,7 @@ describe API::Search do context 'when scope is missing' do it 'returns 400 error' do - get api("/groups/#{group.id}/-/search", user), search: 'awesome' + get api("/groups/#{group.id}/search", user), search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -126,7 +126,7 @@ describe API::Search do context 'when group does not exist' do it 'returns 404 error' do - get api('/groups/9999/-/search', user), scope: 'issues', search: 'awesome' + get api('/groups/9999/search', user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -136,7 +136,7 @@ describe API::Search do it 'returns 404 error' do private_group = create(:group, :private) - get api("/groups/#{private_group.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/groups/#{private_group.id}/search", user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -145,7 +145,7 @@ describe API::Search do context 'with correct params' do context 'for projects scope' do before do - get api("/groups/#{group.id}/-/search", user), scope: 'projects', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'projects', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/projects' @@ -155,7 +155,7 @@ describe API::Search do before do create(:issue, project: project, title: 'awesome issue') - get api("/groups/#{group.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'issues', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/issues' @@ -165,7 +165,7 @@ describe API::Search do before do create(:merge_request, source_project: repo_project, title: 'awesome mr') - get api("/groups/#{group.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'merge_requests', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' @@ -175,7 +175,7 @@ describe API::Search do before do create(:milestone, project: project, title: 'awesome milestone') - get api("/groups/#{group.id}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -187,7 +187,7 @@ describe API::Search do create(:milestone, project: project, title: 'awesome milestone') create(:milestone, project: another_project, title: 'awesome milestone other project') - get api("/groups/#{CGI.escape(group.full_path)}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/groups/#{CGI.escape(group.full_path)}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -198,7 +198,7 @@ describe API::Search do describe "GET /projects/:id/search" do context 'when user is not authenticated' do it 'returns 401 error' do - get api("/projects/#{project.id}/-/search"), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search"), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(401) end @@ -206,7 +206,7 @@ describe API::Search do context 'when scope is not supported' do it 'returns 400 error' do - get api("/projects/#{project.id}/-/search", user), scope: 'unsupported', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'unsupported', search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -214,7 +214,7 @@ describe API::Search do context 'when scope is missing' do it 'returns 400 error' do - get api("/projects/#{project.id}/-/search", user), search: 'awesome' + get api("/projects/#{project.id}/search", user), search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -222,7 +222,7 @@ describe API::Search do context 'when project does not exist' do it 'returns 404 error' do - get api('/projects/9999/-/search', user), scope: 'issues', search: 'awesome' + get api('/projects/9999/search', user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -232,7 +232,7 @@ describe API::Search do it 'returns 404 error' do project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -243,7 +243,7 @@ describe API::Search do before do create(:issue, project: project, title: 'awesome issue') - get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/issues' @@ -253,7 +253,7 @@ describe API::Search do before do create(:merge_request, source_project: repo_project, title: 'awesome mr') - get api("/projects/#{repo_project.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + get api("/projects/#{repo_project.id}/search", user), scope: 'merge_requests', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' @@ -263,7 +263,7 @@ describe API::Search do before do create(:milestone, project: project, title: 'awesome milestone') - get api("/projects/#{project.id}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -273,7 +273,7 @@ describe API::Search do before do create(:note_on_merge_request, project: project, note: 'awesome note') - get api("/projects/#{project.id}/-/search", user), scope: 'notes', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'notes', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/notes' @@ -284,7 +284,7 @@ describe API::Search do wiki = create(:project_wiki, project: project) create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" }) - get api("/projects/#{project.id}/-/search", user), scope: 'wiki_blobs', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'wiki_blobs', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/blobs' @@ -292,7 +292,7 @@ describe API::Search do context 'for commits scope' do before do - get api("/projects/#{repo_project.id}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + get api("/projects/#{repo_project.id}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' end it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details' @@ -300,7 +300,7 @@ describe API::Search do context 'for commits scope with project path as id' do before do - get api("/projects/#{CGI.escape(repo_project.full_path)}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + get api("/projects/#{CGI.escape(repo_project.full_path)}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' end it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details' @@ -308,7 +308,7 @@ describe API::Search do context 'for blobs scope' do before do - get api("/projects/#{repo_project.id}/-/search", user), scope: 'blobs', search: 'monitors' + get api("/projects/#{repo_project.id}/search", user), scope: 'blobs', search: 'monitors' end it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2 -- cgit v1.2.1 From a200619d14bf1d90c21503ec358a30ca84d5337f Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Mon, 19 Mar 2018 19:06:09 +0000 Subject: Show Ajax requests in performance bar But first, rewrite the performance bar in Vue: 1. Remove the peek-host gem and replace it with existing code. This also allows us to include the host in the JSON response, rather than in the page HTML. 2. Leave the line profiler parts as here-be-dragons: nicer would be a separate endpoint for these, so we could use them on Ajax requests too. 3. The performance bar is too fiddly to rewrite right now, so apply the same logic to that. Then, add features! All requests made through Axios are able to be tracked. To keep a lid on memory usage, only the first two requests for a given URL are tracked, though. Each request that's tracked has the same data as the initial page load, with the exception of the performance bar and the line profiler, as explained above. --- Gemfile | 1 - Gemfile.lock | 3 - app/assets/javascripts/dispatcher.js | 12 +- app/assets/javascripts/performance_bar.js | 57 ------ .../performance_bar/components/detailed_metric.vue | 78 +++++++++ .../components/performance_bar_app.vue | 191 +++++++++++++++++++++ .../components/request_selector.vue | 52 ++++++ .../performance_bar/components/simple_metric.vue | 30 ++++ .../components/upstream_performance_bar.vue | 18 ++ app/assets/javascripts/performance_bar/index.js | 37 ++++ .../services/performance_bar_service.js | 24 +++ .../stores/performance_bar_store.js | 39 +++++ app/assets/stylesheets/performance_bar.scss | 29 +++- app/views/peek/_bar.html.haml | 12 ++ app/views/peek/views/_gitaly.html.haml | 17 -- app/views/peek/views/_host.html.haml | 2 - app/views/peek/views/_mysql2.html.haml | 4 - app/views/peek/views/_pg.html.haml | 4 - app/views/peek/views/_rblineprof.html.haml | 7 - app/views/peek/views/_sql.html.haml | 14 -- .../ajax-requests-in-performance-bar.yml | 5 + config/routes.rb | 2 +- .../monitoring/performance/performance_bar.md | 6 +- lib/peek/views/host.rb | 9 + .../user_can_display_performance_bar_spec.rb | 10 +- .../components/detailed_metric_spec.js | 88 ++++++++++ .../components/performance_bar_app_spec.js | 88 ++++++++++ .../components/request_selector_spec.js | 47 +++++ .../components/simple_metric_spec.js | 47 +++++ vendor/assets/javascripts/peek.js | 86 ---------- 30 files changed, 807 insertions(+), 212 deletions(-) delete mode 100644 app/assets/javascripts/performance_bar.js create mode 100644 app/assets/javascripts/performance_bar/components/detailed_metric.vue create mode 100644 app/assets/javascripts/performance_bar/components/performance_bar_app.vue create mode 100644 app/assets/javascripts/performance_bar/components/request_selector.vue create mode 100644 app/assets/javascripts/performance_bar/components/simple_metric.vue create mode 100644 app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue create mode 100644 app/assets/javascripts/performance_bar/index.js create mode 100644 app/assets/javascripts/performance_bar/services/performance_bar_service.js create mode 100644 app/assets/javascripts/performance_bar/stores/performance_bar_store.js create mode 100644 app/views/peek/_bar.html.haml delete mode 100644 app/views/peek/views/_gitaly.html.haml delete mode 100644 app/views/peek/views/_host.html.haml delete mode 100644 app/views/peek/views/_mysql2.html.haml delete mode 100644 app/views/peek/views/_pg.html.haml delete mode 100644 app/views/peek/views/_rblineprof.html.haml delete mode 100644 app/views/peek/views/_sql.html.haml create mode 100644 changelogs/unreleased/ajax-requests-in-performance-bar.yml create mode 100644 lib/peek/views/host.rb create mode 100644 spec/javascripts/performance_bar/components/detailed_metric_spec.js create mode 100644 spec/javascripts/performance_bar/components/performance_bar_app_spec.js create mode 100644 spec/javascripts/performance_bar/components/request_selector_spec.js create mode 100644 spec/javascripts/performance_bar/components/simple_metric_spec.js delete mode 100644 vendor/assets/javascripts/peek.js diff --git a/Gemfile b/Gemfile index 41b5fe07d82..e423f4ba32f 100644 --- a/Gemfile +++ b/Gemfile @@ -276,7 +276,6 @@ gem 'batch-loader', '~> 1.2.1' # Perf bar gem 'peek', '~> 1.0.1' gem 'peek-gc', '~> 0.0.2' -gem 'peek-host', '~> 1.0.0' gem 'peek-mysql2', '~> 1.1.0', group: :mysql gem 'peek-performance_bar', '~> 1.3.0' gem 'peek-pg', '~> 1.3.0', group: :postgres diff --git a/Gemfile.lock b/Gemfile.lock index 8a37b3c4152..1c6c7edb1a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -593,8 +593,6 @@ GEM railties (>= 4.0.0) peek-gc (0.0.2) peek - peek-host (1.0.0) - peek peek-mysql2 (1.1.0) atomic (>= 1.0.0) mysql2 @@ -1124,7 +1122,6 @@ DEPENDENCIES org-ruby (~> 0.9.12) peek (~> 1.0.1) peek-gc (~> 0.0.2) - peek-host (~> 1.0.0) peek-mysql2 (~> 1.1.0) peek-performance_bar (~> 1.3.0) peek-pg (~> 1.3.0) diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 42ecc415173..72f21f13860 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -53,8 +53,12 @@ function initPageShortcuts(page) { function initGFMInput() { $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { - const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); - const enableGFM = convertPermissionToBoolean(el.dataset.supportsAutocomplete); + const gfm = new GfmAutoComplete( + gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources, + ); + const enableGFM = convertPermissionToBoolean( + el.dataset.supportsAutocomplete, + ); gfm.setup($(el), { emojis: true, members: enableGFM, @@ -67,9 +71,9 @@ function initGFMInput() { } function initPerformanceBar() { - if (document.querySelector('#peek')) { + if (document.querySelector('#js-peek')) { import('./performance_bar') - .then(m => new m.default({ container: '#peek' })) // eslint-disable-line new-cap + .then(m => new m.default({ container: '#js-peek' })) // eslint-disable-line new-cap .catch(() => Flash('Error loading performance bar module')); } } diff --git a/app/assets/javascripts/performance_bar.js b/app/assets/javascripts/performance_bar.js deleted file mode 100644 index c22598ee665..00000000000 --- a/app/assets/javascripts/performance_bar.js +++ /dev/null @@ -1,57 +0,0 @@ -import $ from 'jquery'; -import 'vendor/peek'; -import 'vendor/peek.performance_bar'; -import { getParameterValues } from './lib/utils/url_utility'; - -export default class PerformanceBar { - constructor(opts) { - if (!PerformanceBar.singleton) { - this.init(opts); - PerformanceBar.singleton = this; - } - return PerformanceBar.singleton; - } - - init(opts) { - const $container = $(opts.container); - this.$lineProfileLink = $container.find('.js-toggle-modal-peek-line-profile'); - this.$lineProfileModal = $('#modal-peek-line-profile'); - this.initEventListeners(); - this.showModalOnLoad(); - } - - initEventListeners() { - this.$lineProfileLink.on('click', e => this.handleLineProfileLink(e)); - $(document).on('click', '.js-lineprof-file', PerformanceBar.toggleLineProfileFile); - } - - showModalOnLoad() { - // When a lineprofiler query-string param is present, we show the line - // profiler modal upon page load - if (/lineprofiler/.test(window.location.search)) { - PerformanceBar.toggleModal(this.$lineProfileModal); - } - } - - handleLineProfileLink(e) { - const lineProfilerParameter = getParameterValues('lineprofiler'); - const lineProfilerParameterRegex = new RegExp(`lineprofiler=${lineProfilerParameter[0]}`); - const shouldToggleModal = lineProfilerParameter.length > 0 && - lineProfilerParameterRegex.test(e.currentTarget.href); - - if (shouldToggleModal) { - e.preventDefault(); - PerformanceBar.toggleModal(this.$lineProfileModal); - } - } - - static toggleModal($modal) { - if ($modal.length) { - $modal.modal('toggle'); - } - } - - static toggleLineProfileFile(e) { - $(e.currentTarget).parents('.peek-rblineprof-file').find('.data').toggle(); - } -} diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue new file mode 100644 index 00000000000..145465f4ee9 --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -0,0 +1,78 @@ + + diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue new file mode 100644 index 00000000000..88345cf2ad9 --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -0,0 +1,191 @@ + + diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue new file mode 100644 index 00000000000..2f360ea6f6c --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -0,0 +1,52 @@ + + diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue new file mode 100644 index 00000000000..b654bc66249 --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/simple_metric.vue @@ -0,0 +1,30 @@ + + diff --git a/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue new file mode 100644 index 00000000000..d438b1ec27b --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue @@ -0,0 +1,18 @@ + + diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js new file mode 100644 index 00000000000..fca488120f6 --- /dev/null +++ b/app/assets/javascripts/performance_bar/index.js @@ -0,0 +1,37 @@ +import 'vendor/peek.performance_bar'; + +import Vue from 'vue'; +import performanceBarApp from './components/performance_bar_app.vue'; +import PerformanceBarStore from './stores/performance_bar_store'; + +export default () => + new Vue({ + el: '#js-peek', + components: { + performanceBarApp, + }, + data() { + const performanceBarData = document.querySelector(this.$options.el) + .dataset; + const store = new PerformanceBarStore(); + + return { + store, + env: performanceBarData.env, + requestId: performanceBarData.requestId, + peekUrl: performanceBarData.peekUrl, + profileUrl: performanceBarData.profileUrl, + }; + }, + render(createElement) { + return createElement('performance-bar-app', { + props: { + store: this.store, + env: this.env, + requestId: this.requestId, + peekUrl: this.peekUrl, + profileUrl: this.profileUrl, + }, + }); + }, + }); diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js new file mode 100644 index 00000000000..d8e792446c3 --- /dev/null +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -0,0 +1,24 @@ +import axios from '../../lib/utils/axios_utils'; + +export default class PerformanceBarService { + static fetchRequestDetails(peekUrl, requestId) { + return axios.get(peekUrl, { params: { request_id: requestId } }); + } + + static registerInterceptor(peekUrl, callback) { + return axios.interceptors.response.use(response => { + const requestId = response.headers['x-request-id']; + const requestUrl = response.config.url; + + if (requestUrl !== peekUrl && requestId) { + callback(requestId, requestUrl); + } + + return response; + }); + } + + static removeInterceptor(interceptor) { + axios.interceptors.response.eject(interceptor); + } +} diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js new file mode 100644 index 00000000000..c6b2f55243c --- /dev/null +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -0,0 +1,39 @@ +export default class PerformanceBarStore { + constructor() { + this.requests = []; + } + + addRequest(requestId, requestUrl, requestDetails) { + if (!this.findRequest(requestId)) { + this.requests.push({ + id: requestId, + url: requestUrl, + details: requestDetails, + }); + } + + return this.requests; + } + + findRequest(requestId) { + return this.requests.find(request => request.id === requestId); + } + + addRequestDetails(requestId, requestDetails) { + const request = this.findRequest(requestId); + + request.details = requestDetails; + + return request; + } + + requestsWithDetails() { + return this.requests.filter(request => request.details); + } + + canTrackRequest(requestUrl) { + return ( + this.requests.filter(request => request.url === requestUrl).length < 2 + ); + } +} diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 6e539e39ca1..d06148a7bf8 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -1,8 +1,8 @@ -@import "framework/variables"; -@import "peek/views/performance_bar"; -@import "peek/views/rblineprof"; +@import 'framework/variables'; +@import 'peek/views/performance_bar'; +@import 'peek/views/rblineprof'; -#peek { +#js-peek { position: fixed; left: 0; top: 0; @@ -21,14 +21,26 @@ &.production { background-color: $perf-bar-production; + + select { + background: $perf-bar-production; + } } &.staging { background-color: $perf-bar-staging; + + select { + background: $perf-bar-staging; + } } &.development { background-color: $perf-bar-development; + + select { + background: $perf-bar-development; + } } .wrapper { @@ -42,11 +54,12 @@ background: $perf-bar-bucket-bg; display: inline-block; padding: 4px 6px; - font-family: Consolas, "Liberation Mono", Courier, monospace; + font-family: Consolas, 'Liberation Mono', Courier, monospace; line-height: 1; color: $perf-bar-bucket-color; border-radius: 3px; - box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, inset 0 1px 2px $perf-bar-bucket-box-shadow-to; + box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, + inset 0 1px 2px $perf-bar-bucket-box-shadow-to; .hidden { display: none; @@ -94,6 +107,10 @@ max-width: 10000px !important; } } + + .performance-bar-modal .modal-footer { + display: none; + } } #modal-peek-pg-queries-content { diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml new file mode 100644 index 00000000000..14dafa197b5 --- /dev/null +++ b/app/views/peek/_bar.html.haml @@ -0,0 +1,12 @@ +- return unless peek_enabled? + +#js-peek{ data: { env: Peek.env, + request_id: Peek.request_id, + peek_url: peek_routes.results_url, + profile_url: url_for(params.merge(lineprofiler: 'true')) }, + class: Peek.env } + +#peek-view-performance-bar + = render_server_response_time + %span#serverstats + %ul.performance-bar diff --git a/app/views/peek/views/_gitaly.html.haml b/app/views/peek/views/_gitaly.html.haml deleted file mode 100644 index 945bb287429..00000000000 --- a/app/views/peek/views/_gitaly.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -- local_assigns.fetch(:view) - -%button.btn-blank.btn-link.bold{ type: 'button', data: { toggle: 'modal', target: '#modal-peek-gitaly-details' } } - %span{ data: { defer_to: "#{view.defer_key}-duration" } }... - \/ - %span{ data: { defer_to: "#{view.defer_key}-calls" } }... -#modal-peek-gitaly-details.modal{ tabindex: -1, role: 'dialog' } - .modal-dialog.modal-full - .modal-content - .modal-header - %button.close{ type: 'button', data: { dismiss: 'modal' }, 'aria-label' => 'Close' } - %span{ 'aria-hidden' => 'true' } - × - %h4 - Gitaly requests - .modal-body{ data: { defer_to: "#{view.defer_key}-details" } }... -gitaly diff --git a/app/views/peek/views/_host.html.haml b/app/views/peek/views/_host.html.haml deleted file mode 100644 index 40769b5c6f6..00000000000 --- a/app/views/peek/views/_host.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%span.current-host - = truncate(view.hostname) diff --git a/app/views/peek/views/_mysql2.html.haml b/app/views/peek/views/_mysql2.html.haml deleted file mode 100644 index ac811a10ef5..00000000000 --- a/app/views/peek/views/_mysql2.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- local_assigns.fetch(:view) - -= render 'peek/views/sql', view: view -mysql diff --git a/app/views/peek/views/_pg.html.haml b/app/views/peek/views/_pg.html.haml deleted file mode 100644 index ee94c2f3274..00000000000 --- a/app/views/peek/views/_pg.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- local_assigns.fetch(:view) - -= render 'peek/views/sql', view: view -pg diff --git a/app/views/peek/views/_rblineprof.html.haml b/app/views/peek/views/_rblineprof.html.haml deleted file mode 100644 index 6c037930ca9..00000000000 --- a/app/views/peek/views/_rblineprof.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -Profile: - -= link_to 'all', url_for(lineprofiler: 'true'), class: 'js-toggle-modal-peek-line-profile' -\/ -= link_to 'app & lib', url_for(lineprofiler: 'app'), class: 'js-toggle-modal-peek-line-profile' -\/ -= link_to 'views', url_for(lineprofiler: 'views'), class: 'js-toggle-modal-peek-line-profile' diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml deleted file mode 100644 index 36583df898a..00000000000 --- a/app/views/peek/views/_sql.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -%button.btn-blank.btn-link.bold{ type: 'button', data: { toggle: 'modal', target: '#modal-peek-pg-queries' } } - %span{ data: { defer_to: "#{view.defer_key}-duration" } }... - \/ - %span{ data: { defer_to: "#{view.defer_key}-calls" } }... -#modal-peek-pg-queries.modal{ tabindex: -1 } - .modal-dialog.modal-full - .modal-content - .modal-header - %button.close{ type: 'button', data: { dismiss: 'modal' }, 'aria-label' => 'Close' } - %span{ 'aria-hidden' => 'true' } - × - %h4 - SQL queries - .modal-body{ data: { defer_to: "#{view.defer_key}-queries" } }... diff --git a/changelogs/unreleased/ajax-requests-in-performance-bar.yml b/changelogs/unreleased/ajax-requests-in-performance-bar.yml new file mode 100644 index 00000000000..88cc3678c2b --- /dev/null +++ b/changelogs/unreleased/ajax-requests-in-performance-bar.yml @@ -0,0 +1,5 @@ +--- +title: Allow viewing timings for AJAX requests in the performance bar +merge_request: +author: +type: changed diff --git a/config/routes.rb b/config/routes.rb index 35fd76fb119..8769f433c39 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -44,7 +44,7 @@ Rails.application.routes.draw do get 'readiness' => 'health#readiness' post 'storage_check' => 'health#storage_check' resources :metrics, only: [:index] - mount Peek::Railtie => '/peek' + mount Peek::Railtie => '/peek', as: 'peek_routes' # Boards resources shared between group and projects resources :boards, only: [] do diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md index ec1cbce1bad..dc4f685d843 100644 --- a/doc/administration/monitoring/performance/performance_bar.md +++ b/doc/administration/monitoring/performance/performance_bar.md @@ -13,12 +13,16 @@ It allows you to see (from left to right): ![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png) - time taken and number of [Gitaly] calls, click through for details of these calls ![Gitaly profiling using the Performance Bar](img/performance_bar_gitaly_calls.png) -- profile of the code used to generate the page, line by line for either _all_, _app & lib_ , or _views_. In the profile view, the numbers in the left panel represent wall time, cpu time, and number of calls (based on [rblineprof](https://github.com/tmm1/rblineprof)). +- profile of the code used to generate the page, line by line. In the profile view, the numbers in the left panel represent wall time, cpu time, and number of calls (based on [rblineprof](https://github.com/tmm1/rblineprof)). ![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png) - time taken and number of calls to Redis - time taken and number of background jobs created by Sidekiq - time taken and number of Ruby GC calls +On the far right is a request selector that allows you to view the same metrics +(excluding the page timing and line profiler) for any requests made while the +page was open. Only the first two requests per unique URL are captured. + ## Enable the Performance Bar via the Admin panel GitLab Performance Bar is disabled by default. To enable it for a given group, diff --git a/lib/peek/views/host.rb b/lib/peek/views/host.rb new file mode 100644 index 00000000000..43c8a35c7ea --- /dev/null +++ b/lib/peek/views/host.rb @@ -0,0 +1,9 @@ +module Peek + module Views + class Host < View + def results + { hostname: Gitlab::Environment.hostname } + end + end + end +end diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb index 975c157bcf5..e069c2fddd1 100644 --- a/spec/features/user_can_display_performance_bar_spec.rb +++ b/spec/features/user_can_display_performance_bar_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe 'User can display performance bar', :js do shared_examples 'performance bar cannot be displayed' do it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end context 'when user press `pb`' do @@ -12,14 +12,14 @@ describe 'User can display performance bar', :js do end it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end end end shared_examples 'performance bar can be displayed' do it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end context 'when user press `pb`' do @@ -28,7 +28,7 @@ describe 'User can display performance bar', :js do end it 'shows the performance bar' do - expect(page).to have_css('#peek') + expect(page).to have_css('#js-peek') end end end @@ -41,7 +41,7 @@ describe 'User can display performance bar', :js do it 'shows the performance bar by default' do refresh # Because we're stubbing Rails.env after the 1st visit to root_path - expect(page).to have_css('#peek') + expect(page).to have_css('#js-peek') end end diff --git a/spec/javascripts/performance_bar/components/detailed_metric_spec.js b/spec/javascripts/performance_bar/components/detailed_metric_spec.js new file mode 100644 index 00000000000..eee0210a2a9 --- /dev/null +++ b/spec/javascripts/performance_bar/components/detailed_metric_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import detailedMetric from '~/performance_bar/components/detailed_metric.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('detailedMetric', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('when the current request has no details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(detailedMetric), { + currentRequest: {}, + metric: 'gitaly', + header: 'Gitaly calls', + details: 'details', + keys: ['feature', 'request'], + }); + }); + + it('does not display details', () => { + expect(vm.$el.innerText).not.toContain('/'); + }); + + it('does not display the modal', () => { + expect(vm.$el.querySelector('.performance-bar-modal')).toBeNull(); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); + + describe('when the current request has details', () => { + const requestDetails = [ + { duration: '100', feature: 'find_commit', request: 'abcdef' }, + { duration: '23', feature: 'rebase_in_progress', request: '' }, + ]; + + beforeEach(() => { + vm = mountComponent(Vue.extend(detailedMetric), { + currentRequest: { + details: { + gitaly: { + duration: '123ms', + calls: '456', + details: requestDetails, + }, + }, + }, + metric: 'gitaly', + header: 'Gitaly calls', + details: 'details', + keys: ['feature', 'request'], + }); + }); + + it('diplays details', () => { + expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456'); + }); + + it('adds a modal with a table of the details', () => { + vm.$el + .querySelectorAll('.performance-bar-modal td strong') + .forEach((duration, index) => { + expect(duration.innerText).toContain(requestDetails[index].duration); + }); + + vm.$el + .querySelectorAll('.performance-bar-modal td:nth-child(2)') + .forEach((feature, index) => { + expect(feature.innerText).toContain(requestDetails[index].feature); + }); + + vm.$el + .querySelectorAll('.performance-bar-modal td:nth-child(3)') + .forEach((request, index) => { + expect(request.innerText).toContain(requestDetails[index].request); + }); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); +}); diff --git a/spec/javascripts/performance_bar/components/performance_bar_app_spec.js b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js new file mode 100644 index 00000000000..9ab9ab1c9f4 --- /dev/null +++ b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import performanceBarApp from '~/performance_bar/components/performance_bar_app.vue'; +import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; +import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import MockAdapter from 'axios-mock-adapter'; + +describe('performance bar', () => { + let mock; + let vm; + + beforeEach(() => { + const store = new PerformanceBarStore(); + + mock = new MockAdapter(axios); + + mock.onGet('/-/peek/results').reply( + 200, + { + data: { + gc: { + invokes: 0, + invoke_time: '0.00', + use_size: 0, + total_size: 0, + total_object: 0, + gc_time: '0.00', + }, + host: { hostname: 'web-01' }, + }, + }, + {}, + ); + + vm = mountComponent(Vue.extend(performanceBarApp), { + store, + env: 'development', + requestId: '123', + peekUrl: '/-/peek/results', + profileUrl: '?lineprofiler=true', + }); + }); + + afterEach(() => { + vm.$destroy(); + mock.restore(); + }); + + it('sets the class to match the environment', () => { + expect(vm.$el.getAttribute('class')).toContain('development'); + }); + + describe('loadRequestDetails', () => { + beforeEach(() => { + spyOn(vm.store, 'addRequest').and.callThrough(); + }); + + it('does nothing if the request cannot be tracked', () => { + spyOn(vm.store, 'canTrackRequest').and.callFake(() => false); + + vm.loadRequestDetails('123', 'https://gitlab.com/'); + + expect(vm.store.addRequest).not.toHaveBeenCalled(); + }); + + it('adds the request immediately', () => { + vm.loadRequestDetails('123', 'https://gitlab.com/'); + + expect(vm.store.addRequest).toHaveBeenCalledWith( + '123', + 'https://gitlab.com/', + ); + }); + + it('makes an HTTP request for the request details', () => { + spyOn(PerformanceBarService, 'fetchRequestDetails').and.callThrough(); + + vm.loadRequestDetails('456', 'https://gitlab.com/'); + + expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledWith( + '/-/peek/results', + '456', + ); + }); + }); +}); diff --git a/spec/javascripts/performance_bar/components/request_selector_spec.js b/spec/javascripts/performance_bar/components/request_selector_spec.js new file mode 100644 index 00000000000..6108a29f8c4 --- /dev/null +++ b/spec/javascripts/performance_bar/components/request_selector_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import requestSelector from '~/performance_bar/components/request_selector.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('request selector', () => { + const requests = [ + { id: '123', url: 'https://gitlab.com/' }, + { + id: '456', + url: 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1', + }, + { + id: '789', + url: + 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1.json?serializer=widget', + }, + ]; + + let vm; + + beforeEach(() => { + vm = mountComponent(Vue.extend(requestSelector), { + requests, + currentRequest: requests[1], + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + function optionText(requestId) { + return vm.$el.querySelector(`[value='${requestId}']`).innerText.trim(); + } + + it('displays the last component of the path', () => { + expect(optionText(requests[2].id)).toEqual('1.json?serializer=widget'); + }); + + it('keeps the last two components of the path when the last component is numeric', () => { + expect(optionText(requests[1].id)).toEqual('merge_requests/1'); + }); + + it('ignores trailing slashes', () => { + expect(optionText(requests[0].id)).toEqual('gitlab.com'); + }); +}); diff --git a/spec/javascripts/performance_bar/components/simple_metric_spec.js b/spec/javascripts/performance_bar/components/simple_metric_spec.js new file mode 100644 index 00000000000..98b843e9711 --- /dev/null +++ b/spec/javascripts/performance_bar/components/simple_metric_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import simpleMetric from '~/performance_bar/components/simple_metric.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('simpleMetric', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('when the current request has no details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(simpleMetric), { + currentRequest: {}, + metric: 'gitaly', + }); + }); + + it('does not display details', () => { + expect(vm.$el.innerText).not.toContain('/'); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); + + describe('when the current request has details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(simpleMetric), { + currentRequest: { + details: { gitaly: { duration: '123ms', calls: '456' } }, + }, + metric: 'gitaly', + }); + }); + + it('diplays details', () => { + expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456'); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); +}); diff --git a/vendor/assets/javascripts/peek.js b/vendor/assets/javascripts/peek.js deleted file mode 100644 index 7c6d226fa6a..00000000000 --- a/vendor/assets/javascripts/peek.js +++ /dev/null @@ -1,86 +0,0 @@ -/* - * this is a modified version of https://github.com/peek/peek/blob/master/app/assets/javascripts/peek.js - * - * - Removed the dependency on jquery.tipsy - * - Removed the initializeTipsy and toggleBar functions - * - Customized updatePerformanceBar to handle SQL query and Gitaly call lists - * - Changed /peek/results to /-/peek/results - * - Removed the keypress, pjax:end, page:change, and turbolinks:load handlers - */ -(function($) { - var fetchRequestResults, getRequestId, peekEnabled, updatePerformanceBar, createTable, createTableRow; - getRequestId = function() { - return $('#peek').data('requestId'); - }; - peekEnabled = function() { - return $('#peek').length; - }; - updatePerformanceBar = function(results) { - Object.keys(results.data).forEach(function(key) { - Object.keys(results.data[key]).forEach(function(label) { - var data = results.data[key][label]; - var table = createTable(key, label, data); - var target = $('[data-defer-to="' + key + '-' + label + '"]'); - - if (table) { - target.html(table); - } else { - target.text(data); - } - }); - }); - return $(document).trigger('peek:render', [getRequestId(), results]); - }; - createTable = function(key, label, data) { - if (label !== 'queries' && label !== 'details') { - return; - } - - var table = document.createElement('table'); - - for (var i = 0; i < data.length; i += 1) { - table.appendChild(createTableRow(data[i])); - } - - table.className = 'table'; - - return table; - }; - createTableRow = function(row) { - var tr = document.createElement('tr'); - var durationTd = document.createElement('td'); - var strong = document.createElement('strong'); - - strong.append(row['duration'] + 'ms'); - durationTd.appendChild(strong); - tr.appendChild(durationTd); - - ['sql', 'feature', 'enabled', 'request'].forEach(function(key) { - if (!row[key]) { return; } - - var td = document.createElement('td'); - - td.appendChild(document.createTextNode(row[key])); - tr.appendChild(td); - }); - - return tr; - }; - fetchRequestResults = function() { - return $.ajax('/-/peek/results', { - data: { - request_id: getRequestId() - }, - success: function(data, textStatus, xhr) { - return updatePerformanceBar(data); - }, - error: function(xhr, textStatus, error) {} - }); - }; - $(document).on('peek:update', fetchRequestResults); - return $(function() { - if (peekEnabled()) { - return $(this).trigger('peek:update'); - } - }); -})(jQuery); -- cgit v1.2.1 From 2ee197086a87d22c9203c9a3642f9db6d40f54c4 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Fri, 16 Mar 2018 16:09:35 -0300 Subject: Improve JIRA event descriptions --- app/helpers/services_helper.rb | 23 ------------- app/models/project_services/jira_service.rb | 14 ++++++-- app/models/service.rb | 23 +++++++++++++ app/views/shared/_service_settings.html.haml | 2 +- changelogs/unreleased/issue_25542.yml | 5 +++ lib/api/services.rb | 2 +- spec/services/system_note_service_spec.rb | 2 +- spec/views/projects/services/_form.haml_spec.rb | 46 +++++++++++++++++++++++++ 8 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 changelogs/unreleased/issue_25542.yml create mode 100644 spec/views/projects/services/_form.haml_spec.rb diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 240783bc7fd..f435c80c656 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -1,27 +1,4 @@ module ServicesHelper - def service_event_description(event) - case event - when "push", "push_events" - "Event will be triggered by a push to the repository" - when "tag_push", "tag_push_events" - "Event will be triggered when a new tag is pushed to the repository" - when "note", "note_events" - "Event will be triggered when someone adds a comment" - when "issue", "issue_events" - "Event will be triggered when an issue is created/updated/closed" - when "confidential_issue", "confidential_issue_events" - "Event will be triggered when a confidential issue is created/updated/closed" - when "merge_request", "merge_request_events" - "Event will be triggered when a merge request is created/updated/merged" - when "pipeline", "pipeline_events" - "Event will be triggered when a pipeline status changes" - when "wiki_page", "wiki_page_events" - "Event will be triggered when a wiki page is created/updated" - when "commit", "commit_events" - "Event will be triggered when a commit is created/updated" - end - end - def service_event_field_name(event) event = event.pluralize if %w[merge_request issue confidential_issue].include?(event) "#{event}_events" diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 601a6a077f5..ed4bbfb6cfc 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -14,9 +14,8 @@ class JiraService < IssueTrackerService alias_method :project_url, :url - # This is confusing, but JiraService does not really support these events. - # The values here are required to display correct options in the service - # configuration screen. + # When these are false GitLab does not create cross reference + # comments on JIRA except when an issue gets transitioned. def self.supported_events %w(commit merge_request) end @@ -318,4 +317,13 @@ class JiraService < IssueTrackerService url_changed? end + + def self.event_description(event) + case event + when "merge_request", "merge_request_events" + "JIRA comments will be created when an issue gets referenced in a merge request." + when "commit", "commit_events" + "JIRA comments will be created when an issue gets referenced in a commit." + end + end end diff --git a/app/models/service.rb b/app/models/service.rb index 2556db68146..1dcb79157a2 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -304,6 +304,29 @@ class Service < ActiveRecord::Base end end + def self.event_description(event) + case event + when "push", "push_events" + "Event will be triggered by a push to the repository" + when "tag_push", "tag_push_events" + "Event will be triggered when a new tag is pushed to the repository" + when "note", "note_events" + "Event will be triggered when someone adds a comment" + when "issue", "issue_events" + "Event will be triggered when an issue is created/updated/closed" + when "confidential_issue", "confidential_issue_events" + "Event will be triggered when a confidential issue is created/updated/closed" + when "merge_request", "merge_request_events" + "Event will be triggered when a merge request is created/updated/merged" + when "pipeline", "pipeline_events" + "Event will be triggered when a pipeline status changes" + when "wiki_page", "wiki_page_events" + "Event will be triggered when a wiki page is created/updated" + when "commit", "commit_events" + "Event will be triggered when a commit is created/updated" + end + end + def valid_recipients? activated? && !importing? end diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 355b3ac75ae..a41aaed66a3 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -33,7 +33,7 @@ = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] %p.light - = service_event_description(event) + = @service.class.event_description(event) - @service.global_fields.each do |field| - type = field[:type] diff --git a/changelogs/unreleased/issue_25542.yml b/changelogs/unreleased/issue_25542.yml new file mode 100644 index 00000000000..eba491f7e2a --- /dev/null +++ b/changelogs/unreleased/issue_25542.yml @@ -0,0 +1,5 @@ +--- +title: Improve JIRA event descriptions +merge_request: +author: +type: other diff --git a/lib/api/services.rb b/lib/api/services.rb index 6c97659166d..794fdab8f2b 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -735,7 +735,7 @@ module API required: false, name: event_name.to_sym, type: String, - desc: ServicesHelper.service_event_description(event_name) + desc: service.event_description(event_name) } end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index a3893188c6e..e28b0ea5cf2 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -743,7 +743,7 @@ describe SystemNoteService do expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.") end - it "blocks cross reference when #{type.underscore}_events is true" do + it "creates cross reference when #{type.underscore}_events is true" do jira_tracker.update("#{type}_events" => true) expect(cross_reference(type)).to eq(success_message) diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb new file mode 100644 index 00000000000..85167bca115 --- /dev/null +++ b/spec/views/projects/services/_form.haml_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'projects/services/_form' do + let(:project) { create(:redmine_project) } + let(:user) { create(:admin) } + + before do + assign(:project, project) + + allow(controller).to receive(:current_user).and_return(user) + + allow(view).to receive_messages(current_user: user, + can?: true, + current_application_settings: Gitlab::CurrentSettings.current_application_settings) + end + + context 'commit_events and merge_request_events' do + before do + assign(:service, project.redmine_service) + end + + it 'display merge_request_events and commit_events descriptions' do + allow(RedmineService).to receive(:supported_events).and_return(%w(commit merge_request)) + + render + + expect(rendered).to have_content('Event will be triggered when a commit is created/updated') + expect(rendered).to have_content('Event will be triggered when a merge request is created/updated/merged') + end + + context 'when service is JIRA' do + let(:project) { create(:jira_project) } + + before do + assign(:service, project.jira_service) + end + + it 'display merge_request_events and commit_events descriptions' do + render + + expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a commit.') + expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a merge request.') + end + end + end +end -- cgit v1.2.1 From 8d1be1afb96f24306245606ede20e75fee6b73c6 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Mon, 19 Mar 2018 17:23:41 -0500 Subject: add no_data illustration to monitoring empty state --- app/assets/javascripts/monitoring/components/dashboard.vue | 5 +++++ app/assets/javascripts/monitoring/components/empty_state.vue | 6 +++++- app/views/projects/environments/metrics.html.haml | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 8ca94ef3e2a..10b3a4d2fee 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -73,6 +73,10 @@ type: String, required: true, }, + emptyNoDataSvgPath: { + type: String, + required: true, + }, emptyUnableToConnectSvgPath: { type: String, required: true, @@ -188,6 +192,7 @@ :clusters-path="clustersPath" :empty-getting-started-svg-path="emptyGettingStartedSvgPath" :empty-loading-svg-path="emptyLoadingSvgPath" + :empty-no-data-svg-path="emptyNoDataSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" /> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 9517b8ccb67..fbf451fce68 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -27,6 +27,10 @@ type: String, required: true, }, + emptyNoDataSvgPath: { + type: String, + required: true, + }, emptyUnableToConnectSvgPath: { type: String, required: true, @@ -54,7 +58,7 @@ buttonPath: this.documentationPath, }, noData: { - svgUrl: this.emptyUnableToConnectSvgPath, + svgUrl: this.emptyNoDataSvgPath, title: 'No data found', description: `You are connected to the Prometheus server, but there is currently no data to display.`, diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index c151b5acdf7..d6f0b230b58 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -14,6 +14,7 @@ "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), + "empty-no-data-svg-path": image_path('illustrations/monitoring/no_data.svg'), "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json), "deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json), -- cgit v1.2.1 From ca3b2991a5b740c7b780e997fc1c2ef0ddf62840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Mon, 19 Mar 2018 23:45:50 +0100 Subject: Revert "Filter secret CI variable values from logs" This reverts commit ddeefbdd24119fff5bb3c770f9a285f28ca31914. --- config/application.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/application.rb b/config/application.rb index c14f875611c..0ff95e33a9c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -70,7 +70,6 @@ module Gitlab # - Webhook URLs (:hook) # - Sentry DSN (:sentry_dsn) # - Deploy keys (:key) - # - Secret variable values (:secret_value) config.filter_parameters += [/token$/, /password/, /secret/] config.filter_parameters += %i( certificate @@ -82,7 +81,6 @@ module Gitlab sentry_dsn trace variables - secret_value ) # Enable escaping HTML in JSON. -- cgit v1.2.1 From ce847d9db19fac1e2e5402fadd53f7b46c1121cb Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Mon, 19 Mar 2018 22:14:02 -0500 Subject: fix tests for dahboard.vue --- spec/javascripts/monitoring/dashboard_spec.js | 1 + .../javascripts/monitoring/dashboard_state_spec.js | 46 ++++++---------------- 2 files changed, 13 insertions(+), 34 deletions(-) diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 29b355307ef..eba6dcf47c5 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -18,6 +18,7 @@ describe('Dashboard', () => { deploymentEndpoint: null, emptyGettingStartedSvgPath: '/path/to/getting-started.svg', emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', }; diff --git a/spec/javascripts/monitoring/dashboard_state_spec.js b/spec/javascripts/monitoring/dashboard_state_spec.js index df3198dd3e2..b4c5f4baa78 100644 --- a/spec/javascripts/monitoring/dashboard_state_spec.js +++ b/spec/javascripts/monitoring/dashboard_state_spec.js @@ -2,13 +2,22 @@ import Vue from 'vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; import { statePaths } from './mock_data'; -const createComponent = (propsData) => { +function createComponent(props) { const Component = Vue.extend(EmptyState); return new Component({ - propsData, + propsData: { + ...props, + settingsPath: statePaths.settingsPath, + clustersPath: statePaths.clustersPath, + documentationPath: statePaths.documentationPath, + emptyGettingStartedSvgPath: '/path/to/getting-started.svg', + emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', + emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + }, }).$mount(); -}; +} function getTextFromNode(component, selector) { return component.$el.querySelector(selector).firstChild.nodeValue.trim(); @@ -19,11 +28,6 @@ describe('EmptyState', () => { it('currentState', () => { const component = createComponent({ selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.currentState).toBe(component.states.gettingStarted); @@ -32,11 +36,6 @@ describe('EmptyState', () => { it('showButtonDescription returns a description with a link for the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.showButtonDescription).toEqual(true); @@ -45,11 +44,6 @@ describe('EmptyState', () => { it('showButtonDescription returns the description without a link for any other state', () => { const component = createComponent({ selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.showButtonDescription).toEqual(false); @@ -59,12 +53,6 @@ describe('EmptyState', () => { it('should show the gettingStarted state', () => { const component = createComponent({ selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - clustersPath: statePaths.clustersPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); @@ -76,11 +64,6 @@ describe('EmptyState', () => { it('should show the loading state', () => { const component = createComponent({ selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); @@ -92,11 +75,6 @@ describe('EmptyState', () => { it('should show the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); -- cgit v1.2.1 From c1708514f594040deedb87216945a29c3bc28bb9 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Mon, 19 Mar 2018 23:01:17 -0500 Subject: move render_gfm into behaviors directory --- app/assets/javascripts/behaviors/copy_as_gfm.js | 501 --------------------- app/assets/javascripts/behaviors/index.js | 3 +- .../javascripts/behaviors/markdown/copy_as_gfm.js | 501 +++++++++++++++++++++ .../javascripts/behaviors/markdown/render_gfm.js | 17 + .../javascripts/behaviors/markdown/render_math.js | 38 ++ .../behaviors/markdown/render_mermaid.js | 57 +++ app/assets/javascripts/main.js | 1 - app/assets/javascripts/render_gfm.js | 17 - app/assets/javascripts/render_math.js | 38 -- app/assets/javascripts/render_mermaid.js | 57 --- app/assets/javascripts/shortcuts_issuable.js | 2 +- lib/banzai/pipeline/gfm_pipeline.rb | 4 +- spec/features/markdown/copy_as_gfm_spec.rb | 2 +- spec/javascripts/behaviors/copy_as_gfm_spec.js | 2 +- spec/javascripts/issue_show/components/app_spec.js | 3 +- spec/javascripts/merge_request_notes_spec.js | 3 +- spec/javascripts/notes/components/note_app_spec.js | 2 +- spec/javascripts/notes_spec.js | 2 +- spec/javascripts/shortcuts_issuable_spec.js | 2 +- 19 files changed, 625 insertions(+), 627 deletions(-) delete mode 100644 app/assets/javascripts/behaviors/copy_as_gfm.js create mode 100644 app/assets/javascripts/behaviors/markdown/copy_as_gfm.js create mode 100644 app/assets/javascripts/behaviors/markdown/render_gfm.js create mode 100644 app/assets/javascripts/behaviors/markdown/render_math.js create mode 100644 app/assets/javascripts/behaviors/markdown/render_mermaid.js delete mode 100644 app/assets/javascripts/render_gfm.js delete mode 100644 app/assets/javascripts/render_math.js delete mode 100644 app/assets/javascripts/render_mermaid.js diff --git a/app/assets/javascripts/behaviors/copy_as_gfm.js b/app/assets/javascripts/behaviors/copy_as_gfm.js deleted file mode 100644 index f5f4f00d587..00000000000 --- a/app/assets/javascripts/behaviors/copy_as_gfm.js +++ /dev/null @@ -1,501 +0,0 @@ -/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { insertText, getSelectedFragment, nodeMatchesSelector } from '../lib/utils/common_utils'; -import { placeholderImage } from '../lazy_loader'; - -const gfmRules = { - // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert - // GitLab Flavored Markdown (GFM) to HTML. - // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. - // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML - // from GFM should have a handler here, in reverse order. - // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. - InlineDiffFilter: { - 'span.idiff.addition'(el, text) { - return `{+${text}+}`; - }, - 'span.idiff.deletion'(el, text) { - return `{-${text}-}`; - }, - }, - TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el) { - return `[${el.checked ? 'x' : ' '}]`; - }, - }, - ReferenceFilter: { - '.tooltip'(el) { - return ''; - }, - 'a.gfm:not([data-link=true])'(el, text) { - return el.dataset.original || text; - }, - }, - AutolinkFilter: { - 'a'(el, text) { - // Fallback on the regular MarkdownFilter's `a` handler. - if (text !== el.getAttribute('href')) return false; - - return text; - }, - }, - TableOfContentsFilter: { - 'ul.section-nav'(el) { - return '[[_TOC_]]'; - }, - }, - EmojiFilter: { - 'img.emoji'(el) { - return el.getAttribute('alt'); - }, - 'gl-emoji'(el) { - return `:${el.getAttribute('data-name')}:`; - }, - }, - ImageLinkFilter: { - 'a.no-attachment-icon'(el, text) { - return text; - }, - }, - ImageLazyLoadFilter: { - 'img'(el, text) { - return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; - }, - }, - VideoLinkFilter: { - '.video-container'(el) { - const videoEl = el.querySelector('video'); - if (!videoEl) return false; - - return CopyAsGFM.nodeToGFM(videoEl); - }, - 'video'(el) { - return `![${el.dataset.title}](${el.getAttribute('src')})`; - }, - }, - MermaidFilter: { - 'svg.mermaid'(el, text) { - const sourceEl = el.querySelector('text.source'); - if (!sourceEl) return false; - - return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``; - }, - 'svg.mermaid style, svg.mermaid g'(el, text) { - // We don't want to include the content of these elements in the copied text. - return ''; - }, - }, - MathFilter: { - 'pre.code.math[data-math-style=display]'(el, text) { - return `\`\`\`math\n${text.trim()}\n\`\`\``; - }, - 'code.code.math[data-math-style=inline]'(el, text) { - return `$\`${text}\`$`; - }, - 'span.katex-display span.katex-mathml'(el) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; - }, - 'span.katex-mathml'(el) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; - }, - 'span.katex-html'(el) { - // We don't want to include the content of this element in the copied text. - return ''; - }, - 'annotation[encoding="application/x-tex"]'(el, text) { - return text.trim(); - }, - }, - SanitizationFilter: { - 'a[name]:not([href]):empty'(el) { - return el.outerHTML; - }, - 'dl'(el, text) { - let lines = text.trim().split('\n'); - // Add two spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - lines = lines.map((l) => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }); - - return `
\n${lines.join('\n')}\n
`; - }, - 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}`; - }, - }, - SyntaxHighlightFilter: { - 'pre.code.highlight'(el, t) { - const text = t.trimRight(); - - let lang = el.getAttribute('lang'); - if (!lang || lang === 'plaintext') { - lang = ''; - } - - // Prefixes lines with 4 spaces if the code contains triple backticks - if (lang === '' && text.match(/^```/gm)) { - return text.split('\n').map((l) => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }).join('\n'); - } - - return `\`\`\`${lang}\n${text}\n\`\`\``; - }, - 'pre > code'(el, text) { - // Don't wrap code blocks in `` - return text; - }, - }, - MarkdownFilter: { - 'br'(el) { - // Two spaces at the end of a line are turned into a BR - return ' '; - }, - 'code'(el, text) { - let backtickCount = 1; - const backtickMatch = text.match(/`+/); - if (backtickMatch) { - backtickCount = backtickMatch[0].length + 1; - } - - const backticks = Array(backtickCount + 1).join('`'); - const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - - return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; - }, - 'blockquote'(el, text) { - return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); - }, - 'img'(el) { - const imageSrc = el.src; - const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || ''); - return `![${el.getAttribute('alt')}](${imageUrl})`; - }, - 'a.anchor'(el, text) { - // Don't render a Markdown link for the anchor link inside a heading - return text; - }, - 'a'(el, text) { - return `[${text}](${el.getAttribute('href')})`; - }, - 'li'(el, text) { - const lines = text.trim().split('\n'); - const firstLine = `- ${lines.shift()}`; - // Add four spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - const nextLines = lines.map((s) => { - if (s.trim().length === 0) return ''; - - return ` ${s}`; - }); - - return `${firstLine}\n${nextLines.join('\n')}`; - }, - 'ul'(el, text) { - return text; - }, - 'ol'(el, text) { - // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. - return text.replace(/^- /mg, '1. '); - }, - 'h1'(el, text) { - return `# ${text.trim()}`; - }, - 'h2'(el, text) { - return `## ${text.trim()}`; - }, - 'h3'(el, text) { - return `### ${text.trim()}`; - }, - 'h4'(el, text) { - return `#### ${text.trim()}`; - }, - 'h5'(el, text) { - return `##### ${text.trim()}`; - }, - 'h6'(el, text) { - return `###### ${text.trim()}`; - }, - 'strong'(el, text) { - return `**${text}**`; - }, - 'em'(el, text) { - return `_${text}_`; - }, - 'del'(el, text) { - return `~~${text}~~`; - }, - 'sup'(el, text) { - return `^${text}`; - }, - 'hr'(el) { - return '-----'; - }, - 'table'(el) { - const theadEl = el.querySelector('thead'); - const tbodyEl = el.querySelector('tbody'); - if (!theadEl || !tbodyEl) return false; - - const theadText = CopyAsGFM.nodeToGFM(theadEl); - const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - - return [theadText, tbodyText].join('\n'); - }, - 'thead'(el, text) { - const cells = _.map(el.querySelectorAll('th'), (cell) => { - let chars = CopyAsGFM.nodeToGFM(cell).length + 2; - - let before = ''; - let after = ''; - switch (cell.style.textAlign) { - case 'center': - before = ':'; - after = ':'; - chars -= 2; - break; - case 'right': - after = ':'; - chars -= 1; - break; - default: - break; - } - - chars = Math.max(chars, 3); - - const middle = Array(chars + 1).join('-'); - - return before + middle + after; - }); - - const separatorRow = `|${cells.join('|')}|`; - - return [text, separatorRow].join('\n'); - }, - 'tr'(el) { - const cellEls = el.querySelectorAll('td, th'); - if (cellEls.length === 0) return false; - - const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); - return `| ${cells.join(' | ')} |`; - }, - }, -}; - -export class CopyAsGFM { - constructor() { - // iOS currently does not support clipboardData.setData(). This bug should - // be fixed in iOS 12, but for now we'll disable this for all iOS browsers - // ref: https://trac.webkit.org/changeset/222228/webkit - const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; - const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent); - if (isIOS) return; - - $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); - $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); - $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM); - } - - static copyAsGFM(e, transformer) { - const clipboardData = e.originalEvent.clipboardData; - if (!clipboardData) return; - - const documentFragment = getSelectedFragment(); - if (!documentFragment) return; - - const el = transformer(documentFragment.cloneNode(true), e.currentTarget); - if (!el) return; - - e.preventDefault(); - e.stopPropagation(); - - clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); - } - - static pasteGFM(e) { - const clipboardData = e.originalEvent.clipboardData; - if (!clipboardData) return; - - const text = clipboardData.getData('text/plain'); - const gfm = clipboardData.getData('text/x-gfm'); - if (!gfm) return; - - e.preventDefault(); - - window.gl.utils.insertText(e.target, (textBefore, textAfter) => { - // If the text before the cursor contains an odd number of backticks, - // we are either inside an inline code span that starts with 1 backtick - // or a code block that starts with 3 backticks. - // This logic still holds when there are one or more _closed_ code spans - // or blocks that will have 2 or 6 backticks. - // This will break down when the actual code block contains an uneven - // number of backticks, but this is a rare edge case. - const backtickMatch = textBefore.match(/`/g); - const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1; - - if (insideCodeBlock) { - return text; - } - - return gfm; - }); - } - - static transformGFMSelection(documentFragment) { - const gfmElements = documentFragment.querySelectorAll('.md, .wiki'); - switch (gfmElements.length) { - case 0: { - return documentFragment; - } - case 1: { - return gfmElements[0]; - } - default: { - const allGfmElement = document.createElement('div'); - - for (let i = 0; i < gfmElements.length; i += 1) { - const gfmElement = gfmElements[i]; - allGfmElement.appendChild(gfmElement); - allGfmElement.appendChild(document.createTextNode('\n\n')); - } - - return allGfmElement; - } - } - } - - static transformCodeSelection(documentFragment, target) { - let lineSelector = '.line'; - - if (target) { - const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0]; - if (lineClass) { - lineSelector = `.line_content.${lineClass} ${lineSelector}`; - } - } - - const lineElements = documentFragment.querySelectorAll(lineSelector); - - let codeElement; - if (lineElements.length > 1) { - codeElement = document.createElement('pre'); - codeElement.className = 'code highlight'; - - const lang = lineElements[0].getAttribute('lang'); - if (lang) { - codeElement.setAttribute('lang', lang); - } - } else { - codeElement = document.createElement('code'); - } - - if (lineElements.length > 0) { - for (let i = 0; i < lineElements.length; i += 1) { - const lineElement = lineElements[i]; - codeElement.appendChild(lineElement); - codeElement.appendChild(document.createTextNode('\n')); - } - } else { - codeElement.appendChild(documentFragment); - } - - return codeElement; - } - - static nodeToGFM(node, respectWhitespaceParam = false) { - if (node.nodeType === Node.COMMENT_NODE) { - return ''; - } - - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; - } - - const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); - - const text = this.innerGFM(node, respectWhitespace); - - if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return text; - } - - for (const filter in gfmRules) { - const rules = gfmRules[filter]; - - for (const selector in rules) { - const func = rules[selector]; - - if (!nodeMatchesSelector(node, selector)) continue; - - let result; - if (func.length === 2) { - // if `func` takes 2 arguments, it depends on text. - // if there is no text, we don't need to generate GFM for this node. - if (text.length === 0) continue; - - result = func(node, text); - } else { - result = func(node); - } - - if (result === false) continue; - - return result; - } - } - - return text; - } - - static innerGFM(parentNode, respectWhitespace = false) { - const nodes = parentNode.childNodes; - - const clonedParentNode = parentNode.cloneNode(true); - const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); - - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - const clonedNode = clonedNodes[i]; - - const text = this.nodeToGFM(node, respectWhitespace); - - // `clonedNode.replaceWith(text)` is not yet widely supported - clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); - } - - let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; - - if (!respectWhitespace) { - nodeText = nodeText.trim(); - } - - return nodeText; - } -} - -// Export CopyAsGFM as a global for rspec to access -// see /spec/features/copy_as_gfm_spec.rb -if (process.env.NODE_ENV !== 'production') { - window.CopyAsGFM = CopyAsGFM; -} - -export default function initCopyAsGFM() { - return new CopyAsGFM(); -} diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 8d021de7998..84fef4d8b4f 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,6 +1,7 @@ import './autosize'; import './bind_in_out'; -import initCopyAsGFM from './copy_as_gfm'; +import './markdown/render_gfm'; +import initCopyAsGFM from './markdown/copy_as_gfm'; import initCopyToClipboard from './copy_to_clipboard'; import './details_behavior'; import installGlEmojiElement from './gl_emoji'; diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js new file mode 100644 index 00000000000..75cf90de0b5 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -0,0 +1,501 @@ +/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ + +import $ from 'jquery'; +import _ from 'underscore'; +import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils'; +import { placeholderImage } from '~/lazy_loader'; + +const gfmRules = { + // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert + // GitLab Flavored Markdown (GFM) to HTML. + // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. + // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML + // from GFM should have a handler here, in reverse order. + // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. + InlineDiffFilter: { + 'span.idiff.addition'(el, text) { + return `{+${text}+}`; + }, + 'span.idiff.deletion'(el, text) { + return `{-${text}-}`; + }, + }, + TaskListFilter: { + 'input[type=checkbox].task-list-item-checkbox'(el) { + return `[${el.checked ? 'x' : ' '}]`; + }, + }, + ReferenceFilter: { + '.tooltip'(el) { + return ''; + }, + 'a.gfm:not([data-link=true])'(el, text) { + return el.dataset.original || text; + }, + }, + AutolinkFilter: { + 'a'(el, text) { + // Fallback on the regular MarkdownFilter's `a` handler. + if (text !== el.getAttribute('href')) return false; + + return text; + }, + }, + TableOfContentsFilter: { + 'ul.section-nav'(el) { + return '[[_TOC_]]'; + }, + }, + EmojiFilter: { + 'img.emoji'(el) { + return el.getAttribute('alt'); + }, + 'gl-emoji'(el) { + return `:${el.getAttribute('data-name')}:`; + }, + }, + ImageLinkFilter: { + 'a.no-attachment-icon'(el, text) { + return text; + }, + }, + ImageLazyLoadFilter: { + 'img'(el, text) { + return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; + }, + }, + VideoLinkFilter: { + '.video-container'(el) { + const videoEl = el.querySelector('video'); + if (!videoEl) return false; + + return CopyAsGFM.nodeToGFM(videoEl); + }, + 'video'(el) { + return `![${el.dataset.title}](${el.getAttribute('src')})`; + }, + }, + MermaidFilter: { + 'svg.mermaid'(el, text) { + const sourceEl = el.querySelector('text.source'); + if (!sourceEl) return false; + + return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``; + }, + 'svg.mermaid style, svg.mermaid g'(el, text) { + // We don't want to include the content of these elements in the copied text. + return ''; + }, + }, + MathFilter: { + 'pre.code.math[data-math-style=display]'(el, text) { + return `\`\`\`math\n${text.trim()}\n\`\`\``; + }, + 'code.code.math[data-math-style=inline]'(el, text) { + return `$\`${text}\`$`; + }, + 'span.katex-display span.katex-mathml'(el) { + const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!mathAnnotation) return false; + + return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; + }, + 'span.katex-mathml'(el) { + const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!mathAnnotation) return false; + + return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; + }, + 'span.katex-html'(el) { + // We don't want to include the content of this element in the copied text. + return ''; + }, + 'annotation[encoding="application/x-tex"]'(el, text) { + return text.trim(); + }, + }, + SanitizationFilter: { + 'a[name]:not([href]):empty'(el) { + return el.outerHTML; + }, + 'dl'(el, text) { + let lines = text.trim().split('\n'); + // Add two spaces to the front of subsequent list items lines, + // or leave the line entirely blank. + lines = lines.map((l) => { + const line = l.trim(); + if (line.length === 0) return ''; + + return ` ${line}`; + }); + + return `
\n${lines.join('\n')}\n
`; + }, + 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) { + const tag = el.nodeName.toLowerCase(); + return `<${tag}>${text}`; + }, + }, + SyntaxHighlightFilter: { + 'pre.code.highlight'(el, t) { + const text = t.trimRight(); + + let lang = el.getAttribute('lang'); + if (!lang || lang === 'plaintext') { + lang = ''; + } + + // Prefixes lines with 4 spaces if the code contains triple backticks + if (lang === '' && text.match(/^```/gm)) { + return text.split('\n').map((l) => { + const line = l.trim(); + if (line.length === 0) return ''; + + return ` ${line}`; + }).join('\n'); + } + + return `\`\`\`${lang}\n${text}\n\`\`\``; + }, + 'pre > code'(el, text) { + // Don't wrap code blocks in `` + return text; + }, + }, + MarkdownFilter: { + 'br'(el) { + // Two spaces at the end of a line are turned into a BR + return ' '; + }, + 'code'(el, text) { + let backtickCount = 1; + const backtickMatch = text.match(/`+/); + if (backtickMatch) { + backtickCount = backtickMatch[0].length + 1; + } + + const backticks = Array(backtickCount + 1).join('`'); + const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; + + return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; + }, + 'blockquote'(el, text) { + return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); + }, + 'img'(el) { + const imageSrc = el.src; + const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || ''); + return `![${el.getAttribute('alt')}](${imageUrl})`; + }, + 'a.anchor'(el, text) { + // Don't render a Markdown link for the anchor link inside a heading + return text; + }, + 'a'(el, text) { + return `[${text}](${el.getAttribute('href')})`; + }, + 'li'(el, text) { + const lines = text.trim().split('\n'); + const firstLine = `- ${lines.shift()}`; + // Add four spaces to the front of subsequent list items lines, + // or leave the line entirely blank. + const nextLines = lines.map((s) => { + if (s.trim().length === 0) return ''; + + return ` ${s}`; + }); + + return `${firstLine}\n${nextLines.join('\n')}`; + }, + 'ul'(el, text) { + return text; + }, + 'ol'(el, text) { + // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. + return text.replace(/^- /mg, '1. '); + }, + 'h1'(el, text) { + return `# ${text.trim()}`; + }, + 'h2'(el, text) { + return `## ${text.trim()}`; + }, + 'h3'(el, text) { + return `### ${text.trim()}`; + }, + 'h4'(el, text) { + return `#### ${text.trim()}`; + }, + 'h5'(el, text) { + return `##### ${text.trim()}`; + }, + 'h6'(el, text) { + return `###### ${text.trim()}`; + }, + 'strong'(el, text) { + return `**${text}**`; + }, + 'em'(el, text) { + return `_${text}_`; + }, + 'del'(el, text) { + return `~~${text}~~`; + }, + 'sup'(el, text) { + return `^${text}`; + }, + 'hr'(el) { + return '-----'; + }, + 'table'(el) { + const theadEl = el.querySelector('thead'); + const tbodyEl = el.querySelector('tbody'); + if (!theadEl || !tbodyEl) return false; + + const theadText = CopyAsGFM.nodeToGFM(theadEl); + const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); + + return [theadText, tbodyText].join('\n'); + }, + 'thead'(el, text) { + const cells = _.map(el.querySelectorAll('th'), (cell) => { + let chars = CopyAsGFM.nodeToGFM(cell).length + 2; + + let before = ''; + let after = ''; + switch (cell.style.textAlign) { + case 'center': + before = ':'; + after = ':'; + chars -= 2; + break; + case 'right': + after = ':'; + chars -= 1; + break; + default: + break; + } + + chars = Math.max(chars, 3); + + const middle = Array(chars + 1).join('-'); + + return before + middle + after; + }); + + const separatorRow = `|${cells.join('|')}|`; + + return [text, separatorRow].join('\n'); + }, + 'tr'(el) { + const cellEls = el.querySelectorAll('td, th'); + if (cellEls.length === 0) return false; + + const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); + return `| ${cells.join(' | ')} |`; + }, + }, +}; + +export class CopyAsGFM { + constructor() { + // iOS currently does not support clipboardData.setData(). This bug should + // be fixed in iOS 12, but for now we'll disable this for all iOS browsers + // ref: https://trac.webkit.org/changeset/222228/webkit + const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; + const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent); + if (isIOS) return; + + $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); + $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); + $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM); + } + + static copyAsGFM(e, transformer) { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + const documentFragment = getSelectedFragment(); + if (!documentFragment) return; + + const el = transformer(documentFragment.cloneNode(true), e.currentTarget); + if (!el) return; + + e.preventDefault(); + e.stopPropagation(); + + clipboardData.setData('text/plain', el.textContent); + clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); + } + + static pasteGFM(e) { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + const text = clipboardData.getData('text/plain'); + const gfm = clipboardData.getData('text/x-gfm'); + if (!gfm) return; + + e.preventDefault(); + + window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + // If the text before the cursor contains an odd number of backticks, + // we are either inside an inline code span that starts with 1 backtick + // or a code block that starts with 3 backticks. + // This logic still holds when there are one or more _closed_ code spans + // or blocks that will have 2 or 6 backticks. + // This will break down when the actual code block contains an uneven + // number of backticks, but this is a rare edge case. + const backtickMatch = textBefore.match(/`/g); + const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1; + + if (insideCodeBlock) { + return text; + } + + return gfm; + }); + } + + static transformGFMSelection(documentFragment) { + const gfmElements = documentFragment.querySelectorAll('.md, .wiki'); + switch (gfmElements.length) { + case 0: { + return documentFragment; + } + case 1: { + return gfmElements[0]; + } + default: { + const allGfmElement = document.createElement('div'); + + for (let i = 0; i < gfmElements.length; i += 1) { + const gfmElement = gfmElements[i]; + allGfmElement.appendChild(gfmElement); + allGfmElement.appendChild(document.createTextNode('\n\n')); + } + + return allGfmElement; + } + } + } + + static transformCodeSelection(documentFragment, target) { + let lineSelector = '.line'; + + if (target) { + const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0]; + if (lineClass) { + lineSelector = `.line_content.${lineClass} ${lineSelector}`; + } + } + + const lineElements = documentFragment.querySelectorAll(lineSelector); + + let codeElement; + if (lineElements.length > 1) { + codeElement = document.createElement('pre'); + codeElement.className = 'code highlight'; + + const lang = lineElements[0].getAttribute('lang'); + if (lang) { + codeElement.setAttribute('lang', lang); + } + } else { + codeElement = document.createElement('code'); + } + + if (lineElements.length > 0) { + for (let i = 0; i < lineElements.length; i += 1) { + const lineElement = lineElements[i]; + codeElement.appendChild(lineElement); + codeElement.appendChild(document.createTextNode('\n')); + } + } else { + codeElement.appendChild(documentFragment); + } + + return codeElement; + } + + static nodeToGFM(node, respectWhitespaceParam = false) { + if (node.nodeType === Node.COMMENT_NODE) { + return ''; + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent; + } + + const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); + + const text = this.innerGFM(node, respectWhitespace); + + if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + return text; + } + + for (const filter in gfmRules) { + const rules = gfmRules[filter]; + + for (const selector in rules) { + const func = rules[selector]; + + if (!nodeMatchesSelector(node, selector)) continue; + + let result; + if (func.length === 2) { + // if `func` takes 2 arguments, it depends on text. + // if there is no text, we don't need to generate GFM for this node. + if (text.length === 0) continue; + + result = func(node, text); + } else { + result = func(node); + } + + if (result === false) continue; + + return result; + } + } + + return text; + } + + static innerGFM(parentNode, respectWhitespace = false) { + const nodes = parentNode.childNodes; + + const clonedParentNode = parentNode.cloneNode(true); + const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); + + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + const clonedNode = clonedNodes[i]; + + const text = this.nodeToGFM(node, respectWhitespace); + + // `clonedNode.replaceWith(text)` is not yet widely supported + clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); + } + + let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; + + if (!respectWhitespace) { + nodeText = nodeText.trim(); + } + + return nodeText; + } +} + +// Export CopyAsGFM as a global for rspec to access +// see /spec/features/copy_as_gfm_spec.rb +if (process.env.NODE_ENV !== 'production') { + window.CopyAsGFM = CopyAsGFM; +} + +export default function initCopyAsGFM() { + return new CopyAsGFM(); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js new file mode 100644 index 00000000000..dbff2bd4b10 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -0,0 +1,17 @@ +import $ from 'jquery'; +import syntaxHighlight from '~/syntax_highlight'; +import renderMath from './render_math'; +import renderMermaid from './render_mermaid'; + +// Render Gitlab flavoured Markdown +// +// Delegates to syntax highlight and render math & mermaid diagrams. +// +$.fn.renderGFM = function renderGFM() { + syntaxHighlight(this.find('.js-syntax-highlight')); + renderMath(this.find('.js-render-math')); + renderMermaid(this.find('.js-render-mermaid')); + return this; +}; + +$(() => $('body').renderGFM()); diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js new file mode 100644 index 00000000000..7dcf1aeed17 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -0,0 +1,38 @@ +import $ from 'jquery'; +import { __ } from '~/locale'; +import flash from '~/flash'; + +// Renders math using KaTeX in any element with the +// `js-render-math` class +// +// ### Example Markup +// +// +// + +// Loop over all math elements and render math +function renderWithKaTeX(elements, katex) { + elements.each(function katexElementsLoop() { + const mathNode = $(''); + const $this = $(this); + + const display = $this.attr('data-math-style') === 'display'; + try { + katex.render($this.text(), mathNode.get(0), { displayMode: display, throwOnError: false }); + mathNode.insertAfter($this); + $this.remove(); + } catch (err) { + throw err; + } + }); +} + +export default function renderMath($els) { + if (!$els.length) return; + Promise.all([ + import(/* webpackChunkName: 'katex' */ 'katex'), + import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.css'), + ]).then(([katex]) => { + renderWithKaTeX($els, katex); + }).catch(() => flash(__('An error occurred while rendering KaTeX'))); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js new file mode 100644 index 00000000000..56b1896e9f1 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -0,0 +1,57 @@ +import flash from '~/flash'; + +// Renders diagrams and flowcharts from text using Mermaid in any element with the +// `js-render-mermaid` class. +// +// Example markup: +// +//
+//  graph TD;
+//    A-- > B;
+//    A-- > C;
+//    B-- > D;
+//    C-- > D;
+// 
+// + +export default function renderMermaid($els) { + if (!$els.length) return; + + import(/* webpackChunkName: 'mermaid' */ 'blackst0ne-mermaid').then((mermaid) => { + mermaid.initialize({ + // mermaid core options + mermaid: { + startOnLoad: false, + }, + // mermaidAPI options + theme: 'neutral', + }); + + $els.each((i, el) => { + const source = el.textContent; + + // Remove any extra spans added by the backend syntax highlighting. + Object.assign(el, { textContent: source }); + + mermaid.init(undefined, el, (id) => { + const svg = document.getElementById(id); + + svg.classList.add('mermaid'); + + // pre > code > svg + svg.closest('pre').replaceWith(svg); + + // We need to add the original source into the DOM to allow Copy-as-GFM + // to access it. + const sourceEl = document.createElement('text'); + sourceEl.classList.add('source'); + sourceEl.setAttribute('display', 'none'); + sourceEl.textContent = source; + + svg.appendChild(sourceEl); + }); + }); + }).catch((err) => { + flash(`Can't load mermaid module: ${err}`); + }); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 870285f7940..cedb6ef19f7 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -32,7 +32,6 @@ import LazyLoader from './lazy_loader'; import initLogoAnimation from './logo'; import './milestone_select'; import './projects_dropdown'; -import './render_gfm'; import initBreadcrumbs from './breadcrumb'; import initDispatcher from './dispatcher'; diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js deleted file mode 100644 index 94fffcd2f61..00000000000 --- a/app/assets/javascripts/render_gfm.js +++ /dev/null @@ -1,17 +0,0 @@ -import $ from 'jquery'; -import renderMath from './render_math'; -import renderMermaid from './render_mermaid'; -import syntaxHighlight from './syntax_highlight'; - -// Render Gitlab flavoured Markdown -// -// Delegates to syntax highlight and render math & mermaid diagrams. -// -$.fn.renderGFM = function renderGFM() { - syntaxHighlight(this.find('.js-syntax-highlight')); - renderMath(this.find('.js-render-math')); - renderMermaid(this.find('.js-render-mermaid')); - return this; -}; - -$(() => $('body').renderGFM()); diff --git a/app/assets/javascripts/render_math.js b/app/assets/javascripts/render_math.js deleted file mode 100644 index 8572bf64d46..00000000000 --- a/app/assets/javascripts/render_math.js +++ /dev/null @@ -1,38 +0,0 @@ -import $ from 'jquery'; -import { __ } from './locale'; -import flash from './flash'; - -// Renders math using KaTeX in any element with the -// `js-render-math` class -// -// ### Example Markup -// -// -// - -// Loop over all math elements and render math -function renderWithKaTeX(elements, katex) { - elements.each(function katexElementsLoop() { - const mathNode = $(''); - const $this = $(this); - - const display = $this.attr('data-math-style') === 'display'; - try { - katex.render($this.text(), mathNode.get(0), { displayMode: display, throwOnError: false }); - mathNode.insertAfter($this); - $this.remove(); - } catch (err) { - throw err; - } - }); -} - -export default function renderMath($els) { - if (!$els.length) return; - Promise.all([ - import(/* webpackChunkName: 'katex' */ 'katex'), - import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.css'), - ]).then(([katex]) => { - renderWithKaTeX($els, katex); - }).catch(() => flash(__('An error occurred while rendering KaTeX'))); -} diff --git a/app/assets/javascripts/render_mermaid.js b/app/assets/javascripts/render_mermaid.js deleted file mode 100644 index d4f18955bd2..00000000000 --- a/app/assets/javascripts/render_mermaid.js +++ /dev/null @@ -1,57 +0,0 @@ -// Renders diagrams and flowcharts from text using Mermaid in any element with the -// `js-render-mermaid` class. -// -// Example markup: -// -//
-//  graph TD;
-//    A-- > B;
-//    A-- > C;
-//    B-- > D;
-//    C-- > D;
-// 
-// - -import Flash from './flash'; - -export default function renderMermaid($els) { - if (!$els.length) return; - - import(/* webpackChunkName: 'mermaid' */ 'blackst0ne-mermaid').then((mermaid) => { - mermaid.initialize({ - // mermaid core options - mermaid: { - startOnLoad: false, - }, - // mermaidAPI options - theme: 'neutral', - }); - - $els.each((i, el) => { - const source = el.textContent; - - // Remove any extra spans added by the backend syntax highlighting. - Object.assign(el, { textContent: source }); - - mermaid.init(undefined, el, (id) => { - const svg = document.getElementById(id); - - svg.classList.add('mermaid'); - - // pre > code > svg - svg.closest('pre').replaceWith(svg); - - // We need to add the original source into the DOM to allow Copy-as-GFM - // to access it. - const sourceEl = document.createElement('text'); - sourceEl.classList.add('source'); - sourceEl.setAttribute('display', 'none'); - sourceEl.textContent = source; - - svg.appendChild(sourceEl); - }); - }); - }).catch((err) => { - Flash(`Can't load mermaid module: ${err}`); - }); -} diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 3031230277d..193788f754f 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -3,7 +3,7 @@ import Mousetrap from 'mousetrap'; import _ from 'underscore'; import Sidebar from './right_sidebar'; import Shortcuts from './shortcuts'; -import { CopyAsGFM } from './behaviors/copy_as_gfm'; +import { CopyAsGFM } from './behaviors/markdown/copy_as_gfm'; export default class ShortcutsIssuable extends Shortcuts { constructor(isMergeRequest) { diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index 4001b8a85e3..8b2f05fffec 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -2,10 +2,10 @@ module Banzai module Pipeline class GfmPipeline < BasePipeline # These filters convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js + # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js # consequently convert that same HTML to GFM to be copied to the clipboard. # Every filter that generates HTML from GFM should have a handler in - # app/assets/javascripts/copy_as_gfm.js, in reverse order. + # app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order. # The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. def self.filters @filters ||= FilterArray[ diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index f82ed6300cc..4d897f09b57 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -20,7 +20,7 @@ describe 'Copy as GFM', :js do end # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. + # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM. # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js index b8155144e2a..efbe09a10a2 100644 --- a/spec/javascripts/behaviors/copy_as_gfm_spec.js +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -1,4 +1,4 @@ -import { CopyAsGFM } from '~/behaviors/copy_as_gfm'; +import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; describe('CopyAsGFM', () => { describe('CopyAsGFM.pasteGFM', () => { diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 584db6c6632..d5a87b5ce20 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -1,8 +1,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import '~/render_math'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import * as urlUtils from '~/lib/utils/url_utility'; import issuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js index eb644e698da..dc9dc4d4249 100644 --- a/spec/javascripts/merge_request_notes_spec.js +++ b/spec/javascripts/merge_request_notes_spec.js @@ -3,8 +3,7 @@ import _ from 'underscore'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; -import '~/render_gfm'; -import '~/render_math'; +import '~/behaviors/markdown/render_gfm'; import Notes from '~/notes'; const upArrowKeyCode = 38; diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index ac39418c3e6..0e792eee5e9 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -3,7 +3,7 @@ import _ from 'underscore'; import Vue from 'vue'; import notesApp from '~/notes/components/notes_app.vue'; import service from '~/notes/services/notes_service'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import * as mockData from '../mock_data'; const vueMatchers = { diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index ba0a70bed17..8f317b06792 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -7,7 +7,7 @@ import * as urlUtils from '~/lib/utils/url_utility'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import Notes from '~/notes'; import timeoutPromise from './helpers/set_timeout_promise_helper'; diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index faaf710cf6f..b0d714cbefb 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import initCopyAsGFM from '~/behaviors/copy_as_gfm'; +import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/shortcuts_issuable'; initCopyAsGFM(); -- cgit v1.2.1 From 43ff14e0c5074311fd544d654108550253e0fd93 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Tue, 20 Mar 2018 00:37:16 -0500 Subject: refactor ConfirmDangerModal into ES module --- app/assets/javascripts/confirm_danger_modal.js | 47 ++++++++++---------------- app/assets/javascripts/main.js | 13 +++---- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index 0932d836589..c21c52b0086 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -1,33 +1,22 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, camelcase, one-var-declaration-per-line, no-else-return, max-len */ - import $ from 'jquery'; import { rstrip } from './lib/utils/common_utils'; -window.ConfirmDangerModal = (function() { - function ConfirmDangerModal(form, text) { - var project_path, submit; - this.form = form; - $('.js-confirm-text').text(text || ''); - $('.js-confirm-danger-input').val(''); - $('#modal-confirm-danger').modal('show'); - project_path = $('.js-confirm-danger-match').text(); - submit = $('.js-confirm-danger-submit'); - submit.disable(); - $('.js-confirm-danger-input').off('input'); - $('.js-confirm-danger-input').on('input', function() { - if (rstrip($(this).val()) === project_path) { - return submit.enable(); - } else { - return submit.disable(); - } - }); - $('.js-confirm-danger-submit').off('click'); - $('.js-confirm-danger-submit').on('click', (function(_this) { - return function() { - return _this.form.submit(); - }; - })(this)); - } +export default function initConfirmDangerModal($form, text) { + $('.js-confirm-text').text(text || ''); + $('.js-confirm-danger-input').val(''); + $('#modal-confirm-danger').modal('show'); + + const confirmTextMatch = $('.js-confirm-danger-match').text(); + const $submit = $('.js-confirm-danger-submit'); + $submit.disable(); - return ConfirmDangerModal; -})(); + $('.js-confirm-danger-input').off('input').on('input', function handleInput() { + const confirmText = rstrip($(this).val()); + if (confirmText === confirmTextMatch) { + $submit.enable(); + } else { + $submit.disable(); + } + }); + $('.js-confirm-danger-submit').off('click').on('click', () => $form.submit()); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index cedb6ef19f7..c867f5f3236 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,5 +1,4 @@ /* eslint-disable import/first */ -/* global ConfirmDangerModal */ /* global $ */ import jQuery from 'jquery'; @@ -21,7 +20,7 @@ import './behaviors/'; // everything else import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; -import './confirm_danger_modal'; +import initConfirmDangerModal from './confirm_danger_modal'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; import initTodoToggle from './header'; @@ -215,13 +214,11 @@ document.addEventListener('DOMContentLoaded', () => { }); $document.on('click', '.js-confirm-danger', (e) => { - const btn = $(e.target); - const form = btn.closest('form'); - const text = btn.data('confirmDangerMessage'); e.preventDefault(); - - // eslint-disable-next-line no-new - new ConfirmDangerModal(form, text); + const $btn = $(e.target); + const $form = $btn.closest('form'); + const text = $btn.data('confirmDangerMessage'); + initConfirmDangerModal($form, text); }); $document.on('breakpoint:change', (e, breakpoint) => { -- cgit v1.2.1 From bec7d063ab52b5fb2160ba6fe8e6c8efea7ad8bb Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Tue, 20 Mar 2018 07:52:10 +0100 Subject: Update omniauth-twitter 1.2.0 -> 1.4 --- Gemfile | 2 +- Gemfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index e423f4ba32f..b497774716b 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,7 @@ gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos gem 'omniauth-oauth2-generic', '~> 0.2.2' gem 'omniauth-saml', '~> 1.10' gem 'omniauth-shibboleth', '~> 1.2.0' -gem 'omniauth-twitter', '~> 1.2.0' +gem 'omniauth-twitter', '~> 1.4' gem 'omniauth_crowd', '~> 2.2.0' gem 'omniauth-authentiq', '~> 0.3.1' gem 'rack-oauth2', '~> 1.2.1' diff --git a/Gemfile.lock b/Gemfile.lock index 1c6c7edb1a0..5510b301399 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -515,7 +515,7 @@ GEM nokogiri (1.8.2) mini_portile2 (~> 2.3.0) numerizer (0.1.1) - oauth (0.5.1) + oauth (0.5.4) oauth2 (1.4.0) faraday (>= 0.8, < 0.13) jwt (~> 1.0) @@ -570,9 +570,9 @@ GEM ruby-saml (~> 1.7) omniauth-shibboleth (1.2.1) omniauth (>= 1.0.0) - omniauth-twitter (1.2.1) - json (~> 1.3) + omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) + rack omniauth_crowd (2.2.3) activesupport nokogiri (>= 1.4.4) @@ -1117,7 +1117,7 @@ DEPENDENCIES omniauth-oauth2-generic (~> 0.2.2) omniauth-saml (~> 1.10) omniauth-shibboleth (~> 1.2.0) - omniauth-twitter (~> 1.2.0) + omniauth-twitter (~> 1.4) omniauth_crowd (~> 2.2.0) org-ruby (~> 0.9.12) peek (~> 1.0.1) -- cgit v1.2.1 From 05cda7d58545cd9a495e22a0ad13727aab2aee3c Mon Sep 17 00:00:00 2001 From: Joshua Lambert Date: Tue, 20 Mar 2018 00:10:06 -0700 Subject: Update cloud native charts docs --- doc/install/kubernetes/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md index 3a1707a54a1..aa9b8777359 100644 --- a/doc/install/kubernetes/index.md +++ b/doc/install/kubernetes/index.md @@ -10,7 +10,7 @@ should be deployed, upgraded, and configured. ## Chart Overview * **[GitLab-Omnibus](gitlab_omnibus.md)**: The best way to run GitLab on Kubernetes today, suited for small deployments. The chart is in beta and will be deprecated by the [cloud native GitLab chart](#cloud-native-gitlab-chart). -* **[Cloud Native GitLab Chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md)**: The next generation GitLab chart, currently in development. Will support large deployments with horizontal scaling of individual GitLab components. +* **[Cloud Native GitLab Chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md)**: The next generation GitLab chart, currently in alpha. Will support large deployments with horizontal scaling of individual GitLab components. * Other Charts * [GitLab Runner Chart](gitlab_runner_chart.md): For deploying just the GitLab Runner. * [Advanced GitLab Installation](gitlab_chart.md): Deprecated, being replaced by the [cloud native GitLab chart](#cloud-native-gitlab-chart). Provides additional deployment options, but provides less functionality out-of-the-box. @@ -35,7 +35,7 @@ By offering individual containers and charts, we will be able to provide a numbe * Potential for rolling updates and canaries within a service, * and plenty more. -This is a large project and will be worked on over the span of multiple releases. For the most up-to-date status and release information, please see our [tracking issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420). We are planning to launch this chart in beta by the end of 2017. +Presently this chart is available in alpha for testing, and not recommended for production use. Learn more about the [cloud native GitLab chart here ](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) and [here [Video]](https://youtu.be/Z6jWR8Z8dv8). -- cgit v1.2.1 From ccbceea770e45f615f325880d311dca6b765aca7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 20 Mar 2018 11:59:00 +0100 Subject: Fix end-to-end specs for rebasing a merge request --- qa/qa/page/merge_request/show.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb index 35875487da8..2f2506f08fb 100644 --- a/qa/qa/page/merge_request/show.rb +++ b/qa/qa/page/merge_request/show.rb @@ -4,6 +4,7 @@ module QA class Show < Page::Base view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js' do element :merge_button + element :fast_forward_message, 'Fast-forward merge without a merge commit' end view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue' do @@ -12,19 +13,19 @@ module QA view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue' do element :mr_rebase_button - element :fast_forward_nessage, "Fast-forward merge is not possible" + element :no_fast_forward_message, 'Fast-forward merge is not possible' end def rebase! - wait(reload: false) do - click_element :mr_rebase_button + click_element :mr_rebase_button - has_text?("The source branch HEAD has recently changed.") + wait(reload: false) do + has_text?('Fast-forward merge without a merge commit') end end def fast_forward_possible? - !has_text?("Fast-forward merge is not possible") + !has_text?('Fast-forward merge is not possible') end def has_merge_button? @@ -34,10 +35,10 @@ module QA end def merge! - wait(reload: false) do - click_element :merge_button + click_element :merge_button - has_text?("The changes were merged into") + wait(reload: false) do + has_text?('The changes were merged into') end end end -- cgit v1.2.1 From 4b540d2b625684bb23bfe7f8d51a76d4d60e8760 Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Wed, 14 Mar 2018 13:56:58 +0100 Subject: Fix test failures with licensee 8.9 --- spec/models/repository_spec.rb | 4 ++-- spec/requests/api/templates_spec.rb | 2 +- spec/requests/api/v3/templates_spec.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 5bc972bca14..e506c932d58 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -895,7 +895,7 @@ describe Repository do end it 'returns nil when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Copyright!', + repository.create_file(user, 'LICENSE', 'Gitlab B.V.', message: 'Add LICENSE', branch_name: 'master') expect(repository.license_key).to be_nil @@ -939,7 +939,7 @@ describe Repository do end it 'returns nil when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Copyright!', + repository.create_file(user, 'LICENSE', 'Gitlab B.V.', message: 'Add LICENSE', branch_name: 'master') expect(repository.license).to be_nil diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index de1619f33c1..6bb53fdc98d 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -65,7 +65,7 @@ describe API::Templates do expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) - expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['limitations']).to eq(%w[liability warranty]) expect(json_response['content']).to include('MIT License') end end diff --git a/spec/requests/api/v3/templates_spec.rb b/spec/requests/api/v3/templates_spec.rb index 38a8994eb79..1a637f3cf96 100644 --- a/spec/requests/api/v3/templates_spec.rb +++ b/spec/requests/api/v3/templates_spec.rb @@ -57,7 +57,7 @@ describe API::V3::Templates do expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) - expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['limitations']).to eq(%w[liability warranty]) expect(json_response['content']).to include('MIT License') end end -- cgit v1.2.1 From f527e6e1855f30cf5ca5cb834b2d20438299a70e Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 20 Mar 2018 14:12:48 +0000 Subject: Move IDE to CE This also makes the IDE generally available --- .../ide/components/changed_file_icon.vue | 31 ++ .../ide/components/commit_sidebar/actions.vue | 65 +++ .../ide/components/commit_sidebar/list.vue | 66 +++ .../components/commit_sidebar/list_collapsed.vue | 35 ++ .../ide/components/commit_sidebar/list_item.vue | 60 +++ .../ide/components/commit_sidebar/radio_group.vue | 94 +++++ .../ide/components/editor_mode_dropdown.vue | 91 +++++ app/assets/javascripts/ide/components/ide.vue | 111 +++++ .../javascripts/ide/components/ide_context_bar.vue | 84 ++++ .../ide/components/ide_external_links.vue | 43 ++ .../ide/components/ide_project_branches_tree.vue | 47 +++ .../ide/components/ide_project_tree.vue | 54 +++ .../javascripts/ide/components/ide_repo_tree.vue | 41 ++ .../javascripts/ide/components/ide_side_bar.vue | 51 +++ .../javascripts/ide/components/ide_status_bar.vue | 60 +++ .../ide/components/new_dropdown/index.vue | 111 +++++ .../ide/components/new_dropdown/modal.vue | 99 +++++ .../ide/components/new_dropdown/upload.vue | 75 ++++ .../ide/components/repo_commit_section.vue | 174 ++++++++ .../javascripts/ide/components/repo_editor.vue | 161 ++++++++ .../javascripts/ide/components/repo_file.vue | 127 ++++++ .../ide/components/repo_file_buttons.vue | 61 +++ .../ide/components/repo_file_status_icon.vue | 39 ++ .../ide/components/repo_loading_file.vue | 42 ++ app/assets/javascripts/ide/components/repo_tab.vue | 98 +++++ .../javascripts/ide/components/repo_tabs.vue | 61 +++ .../javascripts/ide/components/resizable_panel.vue | 88 ++++ app/assets/javascripts/ide/eventhub.js | 3 + app/assets/javascripts/ide/ide_router.js | 97 +++++ app/assets/javascripts/ide/index.js | 33 ++ .../javascripts/ide/lib/common/disposable.js | 14 + app/assets/javascripts/ide/lib/common/model.js | 90 +++++ .../javascripts/ide/lib/common/model_manager.js | 51 +++ .../javascripts/ide/lib/decorations/controller.js | 45 +++ app/assets/javascripts/ide/lib/diff/controller.js | 72 ++++ app/assets/javascripts/ide/lib/diff/diff.js | 30 ++ app/assets/javascripts/ide/lib/diff/diff_worker.js | 10 + app/assets/javascripts/ide/lib/editor.js | 164 ++++++++ app/assets/javascripts/ide/lib/editor_options.js | 15 + app/assets/javascripts/ide/lib/themes/gl_theme.js | 14 + app/assets/javascripts/ide/monaco_loader.js | 16 + app/assets/javascripts/ide/services/index.js | 55 +++ app/assets/javascripts/ide/stores/actions.js | 121 ++++++ app/assets/javascripts/ide/stores/actions/file.js | 146 +++++++ .../javascripts/ide/stores/actions/project.js | 49 +++ app/assets/javascripts/ide/stores/actions/tree.js | 93 +++++ app/assets/javascripts/ide/stores/getters.js | 30 ++ app/assets/javascripts/ide/stores/index.js | 19 + .../ide/stores/modules/commit/actions.js | 218 ++++++++++ .../ide/stores/modules/commit/constants.js | 3 + .../ide/stores/modules/commit/getters.js | 24 ++ .../javascripts/ide/stores/modules/commit/index.js | 12 + .../ide/stores/modules/commit/mutation_types.js | 4 + .../ide/stores/modules/commit/mutations.js | 24 ++ .../javascripts/ide/stores/modules/commit/state.js | 6 + .../javascripts/ide/stores/mutation_types.js | 43 ++ app/assets/javascripts/ide/stores/mutations.js | 106 +++++ .../javascripts/ide/stores/mutations/branch.js | 26 ++ .../javascripts/ide/stores/mutations/file.js | 83 ++++ .../javascripts/ide/stores/mutations/project.js | 23 ++ .../javascripts/ide/stores/mutations/tree.js | 38 ++ app/assets/javascripts/ide/stores/state.js | 19 + app/assets/javascripts/ide/stores/utils.js | 125 ++++++ .../ide/stores/workers/files_decorator_worker.js | 91 +++++ app/assets/stylesheets/framework/images.scss | 34 +- app/assets/stylesheets/pages/repo.scss | 365 +++++++++++++---- app/controllers/ide_controller.rb | 6 + app/helpers/ide_helper.rb | 14 + app/views/ide/index.html.haml | 12 + app/views/projects/tree/_tree_header.html.haml | 4 + config/routes.rb | 3 + config/webpack.config.js | 123 +++--- .../projects/tree/create_directory_spec.rb | 53 +++ spec/features/projects/tree/create_file_spec.rb | 43 ++ spec/features/projects/tree/upload_file_spec.rb | 51 +++ .../ide/components/changed_file_icon_spec.js | 45 +++ .../ide/components/commit_sidebar/actions_spec.js | 35 ++ .../commit_sidebar/list_collapsed_spec.js | 28 ++ .../components/commit_sidebar/list_item_spec.js | 83 ++++ .../ide/components/commit_sidebar/list_spec.js | 53 +++ .../components/commit_sidebar/radio_group_spec.js | 130 ++++++ .../ide/components/ide_context_bar_spec.js | 37 ++ .../ide/components/ide_external_links_spec.js | 43 ++ .../ide/components/ide_repo_tree_spec.js | 41 ++ .../ide/components/ide_side_bar_spec.js | 36 ++ spec/javascripts/ide/components/ide_spec.js | 41 ++ .../ide/components/new_dropdown/index_spec.js | 84 ++++ .../ide/components/new_dropdown/modal_spec.js | 72 ++++ .../ide/components/new_dropdown/upload_spec.js | 87 ++++ .../ide/components/repo_commit_section_spec.js | 154 +++++++ .../javascripts/ide/components/repo_editor_spec.js | 137 +++++++ .../ide/components/repo_file_buttons_spec.js | 45 +++ spec/javascripts/ide/components/repo_file_spec.js | 80 ++++ .../ide/components/repo_loading_file_spec.js | 63 +++ spec/javascripts/ide/components/repo_tab_spec.js | 163 ++++++++ spec/javascripts/ide/components/repo_tabs_spec.js | 81 ++++ spec/javascripts/ide/helpers.js | 21 + spec/javascripts/ide/lib/common/disposable_spec.js | 44 ++ .../ide/lib/common/model_manager_spec.js | 123 ++++++ spec/javascripts/ide/lib/common/model_spec.js | 107 +++++ .../ide/lib/decorations/controller_spec.js | 120 ++++++ spec/javascripts/ide/lib/diff/controller_spec.js | 176 ++++++++ spec/javascripts/ide/lib/diff/diff_spec.js | 80 ++++ spec/javascripts/ide/lib/editor_options_spec.js | 11 + spec/javascripts/ide/lib/editor_spec.js | 197 +++++++++ spec/javascripts/ide/monaco_loader_spec.js | 13 + spec/javascripts/ide/stores/actions/file_spec.js | 421 +++++++++++++++++++ spec/javascripts/ide/stores/actions/tree_spec.js | 145 +++++++ spec/javascripts/ide/stores/actions_spec.js | 306 ++++++++++++++ spec/javascripts/ide/stores/getters_spec.js | 55 +++ .../ide/stores/modules/commit/actions_spec.js | 450 +++++++++++++++++++++ .../ide/stores/modules/commit/getters_spec.js | 114 ++++++ .../ide/stores/modules/commit/mutations_spec.js | 42 ++ .../ide/stores/mutations/branch_spec.js | 18 + spec/javascripts/ide/stores/mutations/file_spec.js | 157 +++++++ spec/javascripts/ide/stores/mutations/tree_spec.js | 67 +++ spec/javascripts/ide/stores/mutations_spec.js | 79 ++++ spec/javascripts/ide/stores/utils_spec.js | 60 +++ 118 files changed, 8988 insertions(+), 145 deletions(-) create mode 100644 app/assets/javascripts/ide/components/changed_file_icon.vue create mode 100644 app/assets/javascripts/ide/components/commit_sidebar/actions.vue create mode 100644 app/assets/javascripts/ide/components/commit_sidebar/list.vue create mode 100644 app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue create mode 100644 app/assets/javascripts/ide/components/commit_sidebar/list_item.vue create mode 100644 app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue create mode 100644 app/assets/javascripts/ide/components/editor_mode_dropdown.vue create mode 100644 app/assets/javascripts/ide/components/ide.vue create mode 100644 app/assets/javascripts/ide/components/ide_context_bar.vue create mode 100644 app/assets/javascripts/ide/components/ide_external_links.vue create mode 100644 app/assets/javascripts/ide/components/ide_project_branches_tree.vue create mode 100644 app/assets/javascripts/ide/components/ide_project_tree.vue create mode 100644 app/assets/javascripts/ide/components/ide_repo_tree.vue create mode 100644 app/assets/javascripts/ide/components/ide_side_bar.vue create mode 100644 app/assets/javascripts/ide/components/ide_status_bar.vue create mode 100644 app/assets/javascripts/ide/components/new_dropdown/index.vue create mode 100644 app/assets/javascripts/ide/components/new_dropdown/modal.vue create mode 100644 app/assets/javascripts/ide/components/new_dropdown/upload.vue create mode 100644 app/assets/javascripts/ide/components/repo_commit_section.vue create mode 100644 app/assets/javascripts/ide/components/repo_editor.vue create mode 100644 app/assets/javascripts/ide/components/repo_file.vue create mode 100644 app/assets/javascripts/ide/components/repo_file_buttons.vue create mode 100644 app/assets/javascripts/ide/components/repo_file_status_icon.vue create mode 100644 app/assets/javascripts/ide/components/repo_loading_file.vue create mode 100644 app/assets/javascripts/ide/components/repo_tab.vue create mode 100644 app/assets/javascripts/ide/components/repo_tabs.vue create mode 100644 app/assets/javascripts/ide/components/resizable_panel.vue create mode 100644 app/assets/javascripts/ide/eventhub.js create mode 100644 app/assets/javascripts/ide/ide_router.js create mode 100644 app/assets/javascripts/ide/index.js create mode 100644 app/assets/javascripts/ide/lib/common/disposable.js create mode 100644 app/assets/javascripts/ide/lib/common/model.js create mode 100644 app/assets/javascripts/ide/lib/common/model_manager.js create mode 100644 app/assets/javascripts/ide/lib/decorations/controller.js create mode 100644 app/assets/javascripts/ide/lib/diff/controller.js create mode 100644 app/assets/javascripts/ide/lib/diff/diff.js create mode 100644 app/assets/javascripts/ide/lib/diff/diff_worker.js create mode 100644 app/assets/javascripts/ide/lib/editor.js create mode 100644 app/assets/javascripts/ide/lib/editor_options.js create mode 100644 app/assets/javascripts/ide/lib/themes/gl_theme.js create mode 100644 app/assets/javascripts/ide/monaco_loader.js create mode 100644 app/assets/javascripts/ide/services/index.js create mode 100644 app/assets/javascripts/ide/stores/actions.js create mode 100644 app/assets/javascripts/ide/stores/actions/file.js create mode 100644 app/assets/javascripts/ide/stores/actions/project.js create mode 100644 app/assets/javascripts/ide/stores/actions/tree.js create mode 100644 app/assets/javascripts/ide/stores/getters.js create mode 100644 app/assets/javascripts/ide/stores/index.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/actions.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/constants.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/getters.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/index.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/mutation_types.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/mutations.js create mode 100644 app/assets/javascripts/ide/stores/modules/commit/state.js create mode 100644 app/assets/javascripts/ide/stores/mutation_types.js create mode 100644 app/assets/javascripts/ide/stores/mutations.js create mode 100644 app/assets/javascripts/ide/stores/mutations/branch.js create mode 100644 app/assets/javascripts/ide/stores/mutations/file.js create mode 100644 app/assets/javascripts/ide/stores/mutations/project.js create mode 100644 app/assets/javascripts/ide/stores/mutations/tree.js create mode 100644 app/assets/javascripts/ide/stores/state.js create mode 100644 app/assets/javascripts/ide/stores/utils.js create mode 100644 app/assets/javascripts/ide/stores/workers/files_decorator_worker.js create mode 100644 app/controllers/ide_controller.rb create mode 100644 app/helpers/ide_helper.rb create mode 100644 app/views/ide/index.html.haml create mode 100644 spec/features/projects/tree/create_directory_spec.rb create mode 100644 spec/features/projects/tree/create_file_spec.rb create mode 100644 spec/features/projects/tree/upload_file_spec.rb create mode 100644 spec/javascripts/ide/components/changed_file_icon_spec.js create mode 100644 spec/javascripts/ide/components/commit_sidebar/actions_spec.js create mode 100644 spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js create mode 100644 spec/javascripts/ide/components/commit_sidebar/list_item_spec.js create mode 100644 spec/javascripts/ide/components/commit_sidebar/list_spec.js create mode 100644 spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js create mode 100644 spec/javascripts/ide/components/ide_context_bar_spec.js create mode 100644 spec/javascripts/ide/components/ide_external_links_spec.js create mode 100644 spec/javascripts/ide/components/ide_repo_tree_spec.js create mode 100644 spec/javascripts/ide/components/ide_side_bar_spec.js create mode 100644 spec/javascripts/ide/components/ide_spec.js create mode 100644 spec/javascripts/ide/components/new_dropdown/index_spec.js create mode 100644 spec/javascripts/ide/components/new_dropdown/modal_spec.js create mode 100644 spec/javascripts/ide/components/new_dropdown/upload_spec.js create mode 100644 spec/javascripts/ide/components/repo_commit_section_spec.js create mode 100644 spec/javascripts/ide/components/repo_editor_spec.js create mode 100644 spec/javascripts/ide/components/repo_file_buttons_spec.js create mode 100644 spec/javascripts/ide/components/repo_file_spec.js create mode 100644 spec/javascripts/ide/components/repo_loading_file_spec.js create mode 100644 spec/javascripts/ide/components/repo_tab_spec.js create mode 100644 spec/javascripts/ide/components/repo_tabs_spec.js create mode 100644 spec/javascripts/ide/helpers.js create mode 100644 spec/javascripts/ide/lib/common/disposable_spec.js create mode 100644 spec/javascripts/ide/lib/common/model_manager_spec.js create mode 100644 spec/javascripts/ide/lib/common/model_spec.js create mode 100644 spec/javascripts/ide/lib/decorations/controller_spec.js create mode 100644 spec/javascripts/ide/lib/diff/controller_spec.js create mode 100644 spec/javascripts/ide/lib/diff/diff_spec.js create mode 100644 spec/javascripts/ide/lib/editor_options_spec.js create mode 100644 spec/javascripts/ide/lib/editor_spec.js create mode 100644 spec/javascripts/ide/monaco_loader_spec.js create mode 100644 spec/javascripts/ide/stores/actions/file_spec.js create mode 100644 spec/javascripts/ide/stores/actions/tree_spec.js create mode 100644 spec/javascripts/ide/stores/actions_spec.js create mode 100644 spec/javascripts/ide/stores/getters_spec.js create mode 100644 spec/javascripts/ide/stores/modules/commit/actions_spec.js create mode 100644 spec/javascripts/ide/stores/modules/commit/getters_spec.js create mode 100644 spec/javascripts/ide/stores/modules/commit/mutations_spec.js create mode 100644 spec/javascripts/ide/stores/mutations/branch_spec.js create mode 100644 spec/javascripts/ide/stores/mutations/file_spec.js create mode 100644 spec/javascripts/ide/stores/mutations/tree_spec.js create mode 100644 spec/javascripts/ide/stores/mutations_spec.js create mode 100644 spec/javascripts/ide/stores/utils_spec.js diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue new file mode 100644 index 00000000000..0c54c992e51 --- /dev/null +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue new file mode 100644 index 00000000000..2cbd982af19 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue new file mode 100644 index 00000000000..453208f3f19 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue new file mode 100644 index 00000000000..15918ac9631 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -0,0 +1,35 @@ + + + diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue new file mode 100644 index 00000000000..18934af004a --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue new file mode 100644 index 00000000000..4310d762c78 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue new file mode 100644 index 00000000000..170347881e0 --- /dev/null +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -0,0 +1,91 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue new file mode 100644 index 00000000000..015e750525a --- /dev/null +++ b/app/assets/javascripts/ide/components/ide.vue @@ -0,0 +1,111 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue new file mode 100644 index 00000000000..79a83b47994 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -0,0 +1,84 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue new file mode 100644 index 00000000000..c6f6e0d2348 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_external_links.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue new file mode 100644 index 00000000000..eb2749e6151 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue new file mode 100644 index 00000000000..220db1abfb0 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -0,0 +1,54 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue new file mode 100644 index 00000000000..e6af88e04bc --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue new file mode 100644 index 00000000000..8cf1ccb4fce --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -0,0 +1,51 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue new file mode 100644 index 00000000000..9c386896448 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue new file mode 100644 index 00000000000..769e9b79cad --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -0,0 +1,111 @@ + + + diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue new file mode 100644 index 00000000000..5723891d130 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -0,0 +1,99 @@ + + + diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue new file mode 100644 index 00000000000..c165af5ce52 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -0,0 +1,75 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue new file mode 100644 index 00000000000..d772cab2d0e --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -0,0 +1,174 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue new file mode 100644 index 00000000000..e73d1ce839f --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -0,0 +1,161 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue new file mode 100644 index 00000000000..03a40096bb0 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue new file mode 100644 index 00000000000..4ea8cf7504b --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue new file mode 100644 index 00000000000..25d311142d5 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue new file mode 100644 index 00000000000..79af8c0b0c7 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -0,0 +1,42 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue new file mode 100644 index 00000000000..c337bc813e6 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -0,0 +1,98 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue new file mode 100644 index 00000000000..8ea64ddf84a --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue new file mode 100644 index 00000000000..faa690ecba0 --- /dev/null +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/ide/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js new file mode 100644 index 00000000000..048d5316922 --- /dev/null +++ b/app/assets/javascripts/ide/ide_router.js @@ -0,0 +1,97 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import flash from '~/flash'; +import store from './stores'; + +Vue.use(VueRouter); + +/** + * Routes below /-/ide/: + +/project/h5bp/html5-boilerplate/blob/master +/project/h5bp/html5-boilerplate/blob/master/app/js/test.js + +/project/h5bp/html5-boilerplate/mr/123 +/project/h5bp/html5-boilerplate/mr/123/app/js/test.js + +/workspace/123 +/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch +/workspace/project/h5bp/html5-boilerplate/mr/123 + +/ = /workspace + +/settings +*/ + +// Unfortunately Vue Router doesn't work without at least a fake component +// If you do only data handling +const EmptyRouterComponent = { + render(createElement) { + return createElement('div'); + }, +}; + +const router = new VueRouter({ + mode: 'history', + base: `${gon.relative_url_root}/-/ide/`, + routes: [ + { + path: '/project/:namespace/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode/:branch/*', + component: EmptyRouterComponent, + }, + { + path: 'mr/:mrid', + component: EmptyRouterComponent, + }, + ], + }, + ], +}); + +router.beforeEach((to, from, next) => { + if (to.params.namespace && to.params.project) { + store.dispatch('getProjectData', { + namespace: to.params.namespace, + projectId: to.params.project, + }) + .then(() => { + const fullProjectId = `${to.params.namespace}/${to.params.project}`; + + if (to.params.branch) { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: to.params.branch, + }); + + store.dispatch('getFiles', { + projectId: fullProjectId, + branchId: to.params.branch, + }) + .then(() => { + if (to.params[0]) { + const treeEntry = store.state.entries[to.params[0]]; + if (treeEntry) { + store.dispatch('handleTreeEntryAction', treeEntry); + } + } + }) + .catch((e) => { + flash('Error while loading the branch files. Please try again.', 'alert', document, null, false, true); + throw e; + }); + } + }) + .catch((e) => { + flash('Error while loading the project data. Please try again.', 'alert', document, null, false, true); + throw e; + }); + } + + next(); +}); + +export default router; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js new file mode 100644 index 00000000000..cbfb3dc54f2 --- /dev/null +++ b/app/assets/javascripts/ide/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import ide from './components/ide.vue'; +import store from './stores'; +import router from './ide_router'; + +function initIde(el) { + if (!el) return null; + + return new Vue({ + el, + store, + router, + components: { + ide, + }, + render(createElement) { + return createElement('ide', { + props: { + emptyStateSvgPath: el.dataset.emptyStateSvgPath, + noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, + committedStateSvgPath: el.dataset.committedStateSvgPath, + }, + }); + }, + }); +} + +const ideElement = document.getElementById('ide'); + +Vue.use(Translate); + +initIde(ideElement); diff --git a/app/assets/javascripts/ide/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js new file mode 100644 index 00000000000..84b29bdb600 --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/disposable.js @@ -0,0 +1,14 @@ +export default class Disposable { + constructor() { + this.disposers = new Set(); + } + + add(...disposers) { + disposers.forEach(disposer => this.disposers.add(disposer)); + } + + dispose() { + this.disposers.forEach(disposer => disposer.dispose()); + this.disposers.clear(); + } +} diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js new file mode 100644 index 00000000000..73cd684351c --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -0,0 +1,90 @@ +/* global monaco */ +import Disposable from './disposable'; +import eventHub from '../../eventhub'; + +export default class Model { + constructor(monaco, file) { + this.monaco = monaco; + this.disposable = new Disposable(); + this.file = file; + this.content = file.content !== '' ? file.content : file.raw; + + this.disposable.add( + (this.originalModel = this.monaco.editor.createModel( + this.file.raw, + undefined, + new this.monaco.Uri(null, null, `original/${this.file.path}`), + )), + (this.model = this.monaco.editor.createModel( + this.content, + undefined, + new this.monaco.Uri(null, null, this.file.path), + )), + ); + + this.events = new Map(); + + this.updateContent = this.updateContent.bind(this); + this.dispose = this.dispose.bind(this); + + eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose); + eventHub.$on( + `editor.update.model.content.${this.file.path}`, + this.updateContent, + ); + } + + get url() { + return this.model.uri.toString(); + } + + get language() { + return this.model.getModeId(); + } + + get eol() { + return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; + } + + get path() { + return this.file.path; + } + + getModel() { + return this.model; + } + + getOriginalModel() { + return this.originalModel; + } + + setValue(value) { + this.getModel().setValue(value); + } + + onChange(cb) { + this.events.set( + this.path, + this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))), + ); + } + + updateContent(content) { + this.getOriginalModel().setValue(content); + this.getModel().setValue(content); + } + + dispose() { + this.disposable.dispose(); + this.events.clear(); + + eventHub.$off( + `editor.update.model.dispose.${this.file.path}`, + this.dispose, + ); + eventHub.$off( + `editor.update.model.content.${this.file.path}`, + this.updateContent, + ); + } +} diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js new file mode 100644 index 00000000000..57d5e59a88b --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -0,0 +1,51 @@ +import eventHub from '../../eventhub'; +import Disposable from './disposable'; +import Model from './model'; + +export default class ModelManager { + constructor(monaco) { + this.monaco = monaco; + this.disposable = new Disposable(); + this.models = new Map(); + } + + hasCachedModel(path) { + return this.models.has(path); + } + + getModel(path) { + return this.models.get(path); + } + + addModel(file) { + if (this.hasCachedModel(file.path)) { + return this.getModel(file.path); + } + + const model = new Model(this.monaco, file); + this.models.set(model.path, model); + this.disposable.add(model); + + eventHub.$on( + `editor.update.model.dispose.${file.path}`, + this.removeCachedModel.bind(this, file), + ); + + return model; + } + + removeCachedModel(file) { + this.models.delete(file.path); + + eventHub.$off( + `editor.update.model.dispose.${file.path}`, + this.removeCachedModel, + ); + } + + dispose() { + // dispose of all the models + this.disposable.dispose(); + this.models.clear(); + } +} diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js new file mode 100644 index 00000000000..42904774747 --- /dev/null +++ b/app/assets/javascripts/ide/lib/decorations/controller.js @@ -0,0 +1,45 @@ +export default class DecorationsController { + constructor(editor) { + this.editor = editor; + this.decorations = new Map(); + this.editorDecorations = new Map(); + } + + getAllDecorationsForModel(model) { + if (!this.decorations.has(model.url)) return []; + + const modelDecorations = this.decorations.get(model.url); + const decorations = []; + + modelDecorations.forEach(val => decorations.push(...val)); + + return decorations; + } + + addDecorations(model, decorationsKey, decorations) { + const decorationMap = this.decorations.get(model.url) || new Map(); + + decorationMap.set(decorationsKey, decorations); + + this.decorations.set(model.url, decorationMap); + + this.decorate(model); + } + + decorate(model) { + if (!this.editor.instance) return; + + const decorations = this.getAllDecorationsForModel(model); + const oldDecorations = this.editorDecorations.get(model.url) || []; + + this.editorDecorations.set( + model.url, + this.editor.instance.deltaDecorations(oldDecorations, decorations), + ); + } + + dispose() { + this.decorations.clear(); + this.editorDecorations.clear(); + } +} diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js new file mode 100644 index 00000000000..b136545ad11 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -0,0 +1,72 @@ +/* global monaco */ +import { throttle } from 'underscore'; +import DirtyDiffWorker from './diff_worker'; +import Disposable from '../common/disposable'; + +export const getDiffChangeType = (change) => { + if (change.modified) { + return 'modified'; + } else if (change.added) { + return 'added'; + } else if (change.removed) { + return 'removed'; + } + + return ''; +}; + +export const getDecorator = change => ({ + range: new monaco.Range( + change.lineNumber, + 1, + change.endLineNumber, + 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, + }, +}); + +export default class DirtyDiffController { + constructor(modelManager, decorationsController) { + this.disposable = new Disposable(); + this.editorSimpleWorker = null; + this.modelManager = modelManager; + this.decorationsController = decorationsController; + this.dirtyDiffWorker = new DirtyDiffWorker(); + this.throttledComputeDiff = throttle(this.computeDiff, 250); + this.decorate = this.decorate.bind(this); + + this.dirtyDiffWorker.addEventListener('message', this.decorate); + } + + attachModel(model) { + model.onChange(() => this.throttledComputeDiff(model)); + } + + computeDiff(model) { + this.dirtyDiffWorker.postMessage({ + path: model.path, + originalContent: model.getOriginalModel().getValue(), + newContent: model.getModel().getValue(), + }); + } + + reDecorate(model) { + this.decorationsController.decorate(model); + } + + decorate({ data }) { + const decorations = data.changes.map(change => getDecorator(change)); + const model = this.modelManager.getModel(data.path); + this.decorationsController.addDecorations(model, 'dirtyDiff', decorations); + } + + dispose() { + this.disposable.dispose(); + + this.dirtyDiffWorker.removeEventListener('message', this.decorate); + this.dirtyDiffWorker.terminate(); + } +} diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js new file mode 100644 index 00000000000..0e37f5c4704 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/diff.js @@ -0,0 +1,30 @@ +import { diffLines } from 'diff'; + +// eslint-disable-next-line import/prefer-default-export +export const computeDiff = (originalContent, newContent) => { + const changes = diffLines(originalContent, newContent); + + let lineNumber = 1; + return changes.reduce((acc, change) => { + const findOnLine = acc.find(c => c.lineNumber === lineNumber); + + if (findOnLine) { + Object.assign(findOnLine, change, { + modified: true, + endLineNumber: (lineNumber + change.count) - 1, + }); + } else if ('added' in change || 'removed' in change) { + acc.push(Object.assign({}, change, { + lineNumber, + modified: undefined, + endLineNumber: (lineNumber + change.count) - 1, + })); + } + + if (!change.removed) { + lineNumber += change.count; + } + + return acc; + }, []); +}; diff --git a/app/assets/javascripts/ide/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js new file mode 100644 index 00000000000..e74c4046330 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js @@ -0,0 +1,10 @@ +import { computeDiff } from './diff'; + +self.addEventListener('message', (e) => { + const data = e.data; + + self.postMessage({ + path: data.path, + changes: computeDiff(data.originalContent, data.newContent), + }); +}); diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js new file mode 100644 index 00000000000..38de2fe2b27 --- /dev/null +++ b/app/assets/javascripts/ide/lib/editor.js @@ -0,0 +1,164 @@ +import _ from 'underscore'; +import DecorationsController from './decorations/controller'; +import DirtyDiffController from './diff/controller'; +import Disposable from './common/disposable'; +import ModelManager from './common/model_manager'; +import editorOptions, { defaultEditorOptions } from './editor_options'; +import gitlabTheme from './themes/gl_theme'; + +export const clearDomElement = el => { + if (!el || !el.firstChild) return; + + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +export default class Editor { + static create(monaco) { + if (this.editorInstance) return this.editorInstance; + + this.editorInstance = new Editor(monaco); + + return this.editorInstance; + } + + constructor(monaco) { + this.monaco = monaco; + this.currentModel = null; + this.instance = null; + this.dirtyDiffController = null; + this.disposable = new Disposable(); + this.modelManager = new ModelManager(this.monaco); + this.decorationsController = new DecorationsController(this); + + this.setupMonacoTheme(); + + this.debouncedUpdate = _.debounce(() => { + this.updateDimensions(); + }, 200); + } + + createInstance(domElement) { + if (!this.instance) { + clearDomElement(domElement); + + this.disposable.add( + (this.instance = this.monaco.editor.create(domElement, { + ...defaultEditorOptions, + })), + (this.dirtyDiffController = new DirtyDiffController( + this.modelManager, + this.decorationsController, + )), + ); + + window.addEventListener('resize', this.debouncedUpdate, false); + } + } + + createDiffInstance(domElement) { + if (!this.instance) { + clearDomElement(domElement); + + this.disposable.add( + (this.instance = this.monaco.editor.createDiffEditor(domElement, { + ...defaultEditorOptions, + readOnly: true, + })), + ); + + window.addEventListener('resize', this.debouncedUpdate, false); + } + } + + createModel(file) { + return this.modelManager.addModel(file); + } + + attachModel(model) { + if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') { + this.instance.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + + return; + } + + this.instance.setModel(model.getModel()); + if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model); + + this.currentModel = model; + + this.instance.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach(key => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + + if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model); + } + + setupMonacoTheme() { + this.monaco.editor.defineTheme( + gitlabTheme.themeName, + gitlabTheme.monacoTheme, + ); + + this.monaco.editor.setTheme('gitlab'); + } + + clearEditor() { + if (this.instance) { + this.instance.setModel(null); + } + } + + dispose() { + window.removeEventListener('resize', this.debouncedUpdate); + + // catch any potential errors with disposing the error + // this is mainly for tests caused by elements not existing + try { + this.disposable.dispose(); + + this.instance = null; + } catch (e) { + this.instance = null; + + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.error(e); + } + } + } + + updateDimensions() { + this.instance.layout(); + } + + setPosition({ lineNumber, column }) { + this.instance.revealPositionInCenter({ + lineNumber, + column, + }); + this.instance.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + if (!this.instance.onDidChangeCursorPosition) return; + + this.disposable.add( + this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), + ); + } +} diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js new file mode 100644 index 00000000000..d69d4b8c615 --- /dev/null +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -0,0 +1,15 @@ +export const defaultEditorOptions = { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + minimap: { + enabled: false, + }, +}; + +export default [ + { + readOnly: model => !!model.file.file_lock, + }, +]; diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js new file mode 100644 index 00000000000..2fc96250c7d --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js @@ -0,0 +1,14 @@ +export default { + themeName: 'gitlab', + monacoTheme: { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editorLineNumber.foreground': '#CCCCCC', + 'diffEditor.insertedTextBackground': '#ddfbe6', + 'diffEditor.removedTextBackground': '#f9d7dc', + 'editor.selectionBackground': '#aad6f8', + }, + }, +}; diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js new file mode 100644 index 00000000000..142a220097b --- /dev/null +++ b/app/assets/javascripts/ide/monaco_loader.js @@ -0,0 +1,16 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; + +monacoContext.require.config({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, +}); + +// ignore CDN config and use local assets path for service worker which cannot be cross-domain +const relativeRootPath = (gon && gon.relative_url_root) || ''; +const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`; +window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` }; + +// eslint-disable-next-line no-underscore-dangle +window.__monaco_context__ = monacoContext; +export default monacoContext.require; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js new file mode 100644 index 00000000000..5f1fb6cf843 --- /dev/null +++ b/app/assets/javascripts/ide/services/index.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import Api from '~/api'; + +Vue.use(VueResource); + +export default { + getTreeData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getFileData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getRawFileData(file) { + if (file.tempFile) { + return Promise.resolve(file.content); + } + + if (file.raw) { + return Promise.resolve(file.raw); + } + + return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + .then(res => res.text()); + }, + getProjectData(namespace, project) { + return Api.project(`${namespace}/${project}`); + }, + getBranchData(projectId, currentBranchId) { + return Api.branchSingle(projectId, currentBranchId); + }, + createBranch(projectId, payload) { + const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); + + return Vue.http.post(url, payload); + }, + commit(projectId, payload) { + return Api.commitMultiple(projectId, payload); + }, + getTreeLastCommit(endpoint) { + return Vue.http.get(endpoint, { + params: { + format: 'json', + }, + }); + }, + getFiles(projectUrl, branchId) { + const url = `${projectUrl}/files/${branchId}`; + return Vue.http.get(url, { + params: { + format: 'json', + }, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js new file mode 100644 index 00000000000..7e920aa9f30 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions.js @@ -0,0 +1,121 @@ +import Vue from 'vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import flash from '~/flash'; +import * as types from './mutation_types'; +import FilesDecoratorWorker from './workers/files_decorator_worker'; + +export const redirectToUrl = (_, url) => visitUrl(url); + +export const setInitialData = ({ commit }, data) => + commit(types.SET_INITIAL_DATA, data); + +export const discardAllChanges = ({ state, commit, dispatch }) => { + state.changedFiles.forEach(file => { + commit(types.DISCARD_FILE_CHANGES, file.path); + + if (file.tempFile) { + dispatch('closeFile', file.path); + } + }); + + commit(types.REMOVE_ALL_CHANGES_FILES); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', file.path)); +}; + +export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { + if (side === 'left') { + commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed); + } else { + commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed); + } +}; + +export const setResizingStatus = ({ commit }, resizing) => { + commit(types.SET_RESIZING_STATUS, resizing); +}; + +export const createTempEntry = ( + { state, commit, dispatch }, + { branchId, name, type, content = '', base64 = false }, +) => + new Promise(resolve => { + const worker = new FilesDecoratorWorker(); + const fullName = + name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + + if (state.entries[name]) { + flash( + `The name "${name + .split('/') + .pop()}" is already taken in this directory.`, + 'alert', + document, + null, + false, + true, + ); + + resolve(); + + return null; + } + + worker.addEventListener('message', ({ data }) => { + const { file } = data; + + worker.terminate(); + + commit(types.CREATE_TMP_ENTRY, { + data, + projectId: state.currentProjectId, + branchId, + }); + + if (type === 'blob') { + commit(types.TOGGLE_FILE_OPEN, file.path); + commit(types.ADD_FILE_TO_CHANGED, file.path); + dispatch('setFileActive', file.path); + } + + resolve(file); + }); + + worker.postMessage({ + data: [fullName], + projectId: state.currentProjectId, + branchId, + type, + tempFile: true, + base64, + content, + }); + + return null; + }); + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export const updateViewer = ({ commit }, viewer) => { + commit(types.UPDATE_VIEWER, viewer); +}; + +export const updateDelayViewerUpdated = ({ commit }, delay) => { + commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js new file mode 100644 index 00000000000..ddc4b757bf9 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -0,0 +1,146 @@ +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import flash from '~/flash'; +import eventHub from '../../eventhub'; +import service from '../../services'; +import * as types from '../mutation_types'; +import router from '../../ide_router'; +import { setPageTitle } from '../utils'; + +export const closeFile = ({ commit, state, getters, dispatch }, path) => { + const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); + const file = state.entries[path]; + const fileWasActive = file.active; + + commit(types.TOGGLE_FILE_OPEN, path); + commit(types.SET_FILE_ACTIVE, { path, active: false }); + + if (state.openFiles.length > 0 && fileWasActive) { + const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; + const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path]; + + router.push(`/project${nextFileToOpen.url}`); + } else if (!state.openFiles.length) { + router.push(`/project/${file.projectId}/tree/${file.branchId}/`); + } + + eventHub.$emit(`editor.update.model.dispose.${file.path}`); +}; + +export const setFileActive = ({ commit, state, getters, dispatch }, path) => { + const file = state.entries[path]; + const currentActiveFile = getters.activeFile; + + if (file.active) return; + + if (currentActiveFile) { + commit(types.SET_FILE_ACTIVE, { + path: currentActiveFile.path, + active: false, + }); + } + + commit(types.SET_FILE_ACTIVE, { path, active: true }); + dispatch('scrollToTab'); + + commit(types.SET_CURRENT_PROJECT, file.projectId); + commit(types.SET_CURRENT_BRANCH, file.branchId); +}; + +export const getFileData = ({ state, commit, dispatch }, file) => { + commit(types.TOGGLE_LOADING, { entry: file }); + + return service + .getFileData(file.url) + .then(res => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then(data => { + commit(types.SET_FILE_DATA, { data, file }); + commit(types.TOGGLE_FILE_OPEN, file.path); + dispatch('setFileActive', file.path); + commit(types.TOGGLE_LOADING, { entry: file }); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, { entry: file }); + flash( + 'Error loading file data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + }); +}; + +export const getRawFileData = ({ commit, dispatch }, file) => + service + .getRawFileData(file) + .then(raw => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + }) + .catch(() => + flash( + 'Error loading file content. Please try again.', + 'alert', + document, + null, + false, + true, + ), + ); + +export const changeFileContent = ({ state, commit }, { path, content }) => { + const file = state.entries[path]; + commit(types.UPDATE_FILE_CONTENT, { path, content }); + + const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); + + if (file.changed && indexOfChangedFile === -1) { + commit(types.ADD_FILE_TO_CHANGED, path); + } else if (!file.changed && indexOfChangedFile !== -1) { + commit(types.REMOVE_FILE_FROM_CHANGED, path); + } +}; + +export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { + if (getters.activeFile) { + commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage }); + } +}; + +export const setFileEOL = ({ getters, commit }, { eol }) => { + if (getters.activeFile) { + commit(types.SET_FILE_EOL, { file: getters.activeFile, eol }); + } +}; + +export const setEditorPosition = ( + { getters, commit }, + { editorRow, editorColumn }, +) => { + if (getters.activeFile) { + commit(types.SET_FILE_POSITION, { + file: getters.activeFile, + editorRow, + editorColumn, + }); + } +}; + +export const discardFileChanges = ({ state, commit }, path) => { + const file = state.entries[path]; + + commit(types.DISCARD_FILE_CHANGES, path); + commit(types.REMOVE_FILE_FROM_CHANGED, path); + + if (file.tempFile && file.opened) { + commit(types.TOGGLE_FILE_OPEN, path); + } + + eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); +}; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js new file mode 100644 index 00000000000..b3882cb8d21 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -0,0 +1,49 @@ +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; + +export const getProjectData = ( + { commit, state, dispatch }, + { namespace, projectId, force = false } = {}, +) => new Promise((resolve, reject) => { + if (!state.projects[`${namespace}/${projectId}`] || force) { + commit(types.TOGGLE_LOADING, { entry: state }); + service.getProjectData(namespace, projectId) + .then(res => res.data) + .then((data) => { + commit(types.TOGGLE_LOADING, { entry: state }); + commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); + if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + resolve(data); + }) + .catch(() => { + flash('Error loading project data. Please try again.', 'alert', document, null, false, true); + reject(new Error(`Project not loaded ${namespace}/${projectId}`)); + }); + } else { + resolve(state.projects[`${namespace}/${projectId}`]); + } +}); + +export const getBranchData = ( + { commit, state, dispatch }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { + if ((typeof state.projects[`${projectId}`] === 'undefined' || + !state.projects[`${projectId}`].branches[branchId]) + || force) { + service.getBranchData(`${projectId}`, branchId) + .then(({ data }) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + resolve(data); + }) + .catch(() => { + flash('Error loading branch data. Please try again.', 'alert', document, null, false, true); + reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); + }); + } else { + resolve(state.projects[`${projectId}`].branches[branchId]); + } +}); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js new file mode 100644 index 00000000000..70a969a0325 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -0,0 +1,93 @@ +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + findEntry, +} from '../utils'; +import FilesDecoratorWorker from '../workers/files_decorator_worker'; + +export const toggleTreeOpen = ({ commit, dispatch }, path) => { + commit(types.TOGGLE_TREE_OPEN, path); +}; + +export const handleTreeEntryAction = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', row.path); + } else if (row.type === 'blob' && (row.opened || row.changed)) { + if (row.changed && !row.opened) { + commit(types.TOGGLE_FILE_OPEN, row.path); + } + + dispatch('setFileActive', row.path); + } else { + dispatch('getFileData', row); + } +}; + +export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { + if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; + + service.getTreeLastCommit(tree.lastCommitPath) + .then((res) => { + const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + + commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); + + return res.json(); + }) + .then((data) => { + data.forEach((lastCommit) => { + const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); + + if (entry) { + commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); + } + }); + + dispatch('getLastCommitData', tree); + }) + .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); +}; + +export const getFiles = ( + { state, commit, dispatch }, + { projectId, branchId } = {}, +) => new Promise((resolve, reject) => { + if (!state.trees[`${projectId}/${branchId}`]) { + const selectedProject = state.projects[projectId]; + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + + service + .getFiles(selectedProject.web_url, branchId) + .then(res => res.json()) + .then((data) => { + const worker = new FilesDecoratorWorker(); + worker.addEventListener('message', (e) => { + const { entries, treeList } = e.data; + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_ENTRIES, entries); + commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList }); + commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false }); + + worker.terminate(); + + resolve(); + }); + + worker.postMessage({ + data, + projectId, + branchId, + }); + }) + .catch((e) => { + flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + reject(e); + }); + } else { + resolve(); + } +}); + diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js new file mode 100644 index 00000000000..eba325a31df --- /dev/null +++ b/app/assets/javascripts/ide/stores/getters.js @@ -0,0 +1,30 @@ +export const activeFile = state => + state.openFiles.find(file => file.active) || null; + +export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); + +export const modifiedFiles = state => + state.changedFiles.filter(f => !f.tempFile); + +export const projectsWithTrees = state => + Object.keys(state.projects).map(projectId => { + const project = state.projects[projectId]; + + return { + ...project, + branches: Object.keys(project.branches).map(branchId => { + const branch = project.branches[branchId]; + + return { + ...branch, + tree: state.trees[branch.treeId], + }; + }), + }; + }); + +// eslint-disable-next-line no-confusing-arrow +export const currentIcon = state => + state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; + +export const hasChanges = state => !!state.changedFiles.length; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js new file mode 100644 index 00000000000..7c82ce7976b --- /dev/null +++ b/app/assets/javascripts/ide/stores/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import commitModule from './modules/commit'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, + modules: { + commit: commitModule, + }, +}); diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js new file mode 100644 index 00000000000..2e1aea9a399 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -0,0 +1,218 @@ +import $ from 'jquery'; +import { sprintf, __ } from '~/locale'; +import flash from '~/flash'; +import { stripHtml } from '~/lib/utils/text_utility'; +import * as rootTypes from '../../mutation_types'; +import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; +import router from '../../../ide_router'; +import service from '../../../services'; +import * as types from './mutation_types'; +import * as consts from './constants'; +import eventHub from '../../../eventhub'; + +export const updateCommitMessage = ({ commit }, message) => { + commit(types.UPDATE_COMMIT_MESSAGE, message); +}; + +export const discardDraft = ({ commit }) => { + commit(types.UPDATE_COMMIT_MESSAGE, ''); +}; + +export const updateCommitAction = ({ commit }, commitAction) => { + commit(types.UPDATE_COMMIT_ACTION, commitAction); +}; + +export const updateBranchName = ({ commit }, branchName) => { + commit(types.UPDATE_NEW_BRANCH_NAME, branchName); +}; + +export const setLastCommitMessage = ({ rootState, commit }, data) => { + const currentProject = rootState.projects[rootState.currentProjectId]; + const commitStats = data.stats + ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), { + additions: data.stats.additions, + deletions: data.stats.deletions, + }) + : ''; + const commitMsg = sprintf( + __('Your changes have been committed. Commit %{commitId} %{commitStats}'), + { + commitId: `${data.short_id}`, + commitStats, + }, + false, + ); + + commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true }); +}; + +export const checkCommitStatus = ({ rootState }) => + service + .getBranchData(rootState.currentProjectId, rootState.currentBranchId) + .then(({ data }) => { + const { id } = data.commit; + const selectedBranch = + rootState.projects[rootState.currentProjectId].branches[ + rootState.currentBranchId + ]; + + if (selectedBranch.workingReference !== id) { + return true; + } + + return false; + }) + .catch(() => + flash( + __('Error checking branch data. Please try again.'), + 'alert', + document, + null, + false, + true, + ), + ); + +export const updateFilesAfterCommit = ( + { commit, dispatch, state, rootState, rootGetters }, + { data, branch }, +) => { + const selectedProject = rootState.projects[rootState.currentProjectId]; + const lastCommit = { + commit_path: `${selectedProject.web_url}/commit/${data.id}`, + commit: { + id: data.id, + message: data.message, + authored_date: data.committed_date, + author_name: data.committer_name, + }, + }; + + commit( + rootTypes.SET_BRANCH_WORKING_REFERENCE, + { + projectId: rootState.currentProjectId, + branchId: rootState.currentBranchId, + reference: data.id, + }, + { root: true }, + ); + + rootState.changedFiles.forEach(entry => { + commit( + rootTypes.SET_LAST_COMMIT_DATA, + { + entry, + lastCommit, + }, + { root: true }, + ); + + eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content); + + commit( + rootTypes.SET_FILE_RAW_DATA, + { + file: entry, + raw: entry.content, + }, + { root: true }, + ); + + commit( + rootTypes.TOGGLE_FILE_CHANGED, + { + file: entry, + changed: false, + }, + { root: true }, + ); + }); + + commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); + + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { + router.push( + `/project/${rootState.currentProjectId}/blob/${branch}/${ + rootGetters.activeFile.path + }`, + ); + } + + dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH); +}; + +export const commitChanges = ({ + commit, + state, + getters, + dispatch, + rootState, +}) => { + const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; + const payload = createCommitPayload( + getters.branchName, + newBranch, + state, + rootState, + ); + const getCommitStatus = newBranch + ? Promise.resolve(false) + : dispatch('checkCommitStatus'); + + commit(types.UPDATE_LOADING, true); + + return getCommitStatus + .then( + branchChanged => + new Promise(resolve => { + if (branchChanged) { + // show the modal with a Bootstrap call + $('#ide-create-branch-modal').modal('show'); + } else { + resolve(); + } + }), + ) + .then(() => service.commit(rootState.currentProjectId, payload)) + .then(({ data }) => { + commit(types.UPDATE_LOADING, false); + + if (!data.short_id) { + flash(data.message, 'alert', document, null, false, true); + return; + } + + dispatch('setLastCommitMessage', data); + dispatch('updateCommitMessage', ''); + + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { + dispatch( + 'redirectToUrl', + createNewMergeRequestUrl( + rootState.projects[rootState.currentProjectId].web_url, + getters.branchName, + rootState.currentBranchId, + ), + { root: true }, + ); + } else { + dispatch('updateFilesAfterCommit', { + data, + branch: getters.branchName, + }); + } + }) + .catch(err => { + let errMsg = __('Error committing changes. Please try again.'); + if (err.response.data && err.response.data.message) { + errMsg += ` (${stripHtml(err.response.data.message)})`; + } + flash(errMsg, 'alert', document, null, false, true); + window.dispatchEvent(new Event('resize')); + + commit(types.UPDATE_LOADING, false); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js new file mode 100644 index 00000000000..230b0a3d9b5 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js @@ -0,0 +1,3 @@ +export const COMMIT_TO_CURRENT_BRANCH = '1'; +export const COMMIT_TO_NEW_BRANCH = '2'; +export const COMMIT_TO_NEW_BRANCH_MR = '3'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js new file mode 100644 index 00000000000..f7cdd6adb0c --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -0,0 +1,24 @@ +import * as consts from './constants'; + +export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; + +export const commitButtonDisabled = (state, getters, rootState) => + getters.discardDraftButtonDisabled || !rootState.changedFiles.length; + +export const newBranchName = (state, _, rootState) => + `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`; + +export const branchName = (state, getters, rootState) => { + if ( + state.commitAction === consts.COMMIT_TO_NEW_BRANCH || + state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR + ) { + if (state.newBranchName === '') { + return getters.newBranchName; + } + + return state.newBranchName; + } + + return rootState.currentBranchId; +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js new file mode 100644 index 00000000000..3bf65b02847 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/index.js @@ -0,0 +1,12 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; +import * as getters from './getters'; + +export default { + namespaced: true, + state: state(), + mutations, + actions, + getters, +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js new file mode 100644 index 00000000000..9221f054e9f --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js @@ -0,0 +1,4 @@ +export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE'; +export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; +export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; +export const UPDATE_LOADING = 'UPDATE_LOADING'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js new file mode 100644 index 00000000000..797357e3df9 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -0,0 +1,24 @@ +import * as types from './mutation_types'; + +export default { + [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) { + Object.assign(state, { + commitMessage, + }); + }, + [types.UPDATE_COMMIT_ACTION](state, commitAction) { + Object.assign(state, { + commitAction, + }); + }, + [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) { + Object.assign(state, { + newBranchName, + }); + }, + [types.UPDATE_LOADING](state, submitCommitLoading) { + Object.assign(state, { + submitCommitLoading, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js new file mode 100644 index 00000000000..8dae50961b0 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/commit/state.js @@ -0,0 +1,6 @@ +export default () => ({ + commitMessage: '', + commitAction: '1', + newBranchName: '', + submitCommitLoading: false, +}); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js new file mode 100644 index 00000000000..e28f190897c --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -0,0 +1,43 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; +export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; +export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; +export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; +export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; + +// Project Mutation Types +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; + +// Branch Mutation Types +export const SET_BRANCH = 'SET_BRANCH'; +export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; +export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; + +// Tree mutation types +export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; +export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; +export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; +export const CREATE_TREE = 'CREATE_TREE'; +export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES'; + +// File mutation types +export const SET_FILE_DATA = 'SET_FILE_DATA'; +export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; +export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; +export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; +export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; +export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_EOL = 'SET_FILE_EOL'; +export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; +export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; +export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED'; +export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED'; +export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; +export const SET_ENTRIES = 'SET_ENTRIES'; +export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; +export const UPDATE_VIEWER = 'UPDATE_VIEWER'; +export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js new file mode 100644 index 00000000000..da41fc9285c --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -0,0 +1,106 @@ +import * as types from './mutation_types'; +import projectMutations from './mutations/project'; +import fileMutations from './mutations/file'; +import treeMutations from './mutations/tree'; +import branchMutations from './mutations/branch'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) { + if (entry.path) { + Object.assign(state.entries[entry.path], { + loading: + forceValue !== undefined + ? forceValue + : !state.entries[entry.path].loading, + }); + } else { + Object.assign(entry, { + loading: forceValue !== undefined ? forceValue : !entry.loading, + }); + } + }, + [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) { + Object.assign(state, { + leftPanelCollapsed: collapsed, + }); + }, + [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) { + Object.assign(state, { + rightPanelCollapsed: collapsed, + }); + }, + [types.SET_RESIZING_STATUS](state, resizing) { + Object.assign(state, { + panelResizing: resizing, + }); + }, + [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { + Object.assign(entry.lastCommit, { + id: lastCommit.commit.id, + url: lastCommit.commit_path, + message: lastCommit.commit.message, + author: lastCommit.commit.author_name, + updatedAt: lastCommit.commit.authored_date, + }); + }, + [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) { + Object.assign(state, { + lastCommitMsg, + }); + }, + [types.SET_ENTRIES](state, entries) { + Object.assign(state, { + entries, + }); + }, + [types.CREATE_TMP_ENTRY](state, { data, projectId, branchId }) { + Object.keys(data.entries).reduce((acc, key) => { + const entry = data.entries[key]; + const foundEntry = state.entries[key]; + + if (!foundEntry) { + Object.assign(state.entries, { + [key]: entry, + }); + } else { + const tree = entry.tree.filter( + f => foundEntry.tree.find(e => e.path === f.path) === undefined, + ); + Object.assign(foundEntry, { + tree: foundEntry.tree.concat(tree), + }); + } + + return acc.concat(key); + }, []); + + const foundEntry = state.trees[`${projectId}/${branchId}`].tree.find( + e => e.path === data.treeList[0].path, + ); + + if (!foundEntry) { + Object.assign(state.trees[`${projectId}/${branchId}`], { + tree: state.trees[`${projectId}/${branchId}`].tree.concat( + data.treeList, + ), + }); + } + }, + [types.UPDATE_VIEWER](state, viewer) { + Object.assign(state, { + viewer, + }); + }, + [types.UPDATE_DELAY_VIEWER_CHANGE](state, delayViewerUpdated) { + Object.assign(state, { + delayViewerUpdated, + }); + }, + ...projectMutations, + ...fileMutations, + ...treeMutations, + ...branchMutations, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js new file mode 100644 index 00000000000..2972ba5e38e --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -0,0 +1,26 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranchId) { + Object.assign(state, { + currentBranchId, + }); + }, + [types.SET_BRANCH](state, { projectPath, branchName, branch }) { + Object.assign(state.projects[projectPath], { + branches: { + [branchName]: { + ...branch, + treeId: `${projectPath}/${branchName}`, + active: true, + workingReference: '', + }, + }, + }); + }, + [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + Object.assign(state.projects[projectId].branches[branchId], { + workingReference: reference, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js new file mode 100644 index 00000000000..2500f13db7c --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -0,0 +1,83 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_FILE_ACTIVE](state, { path, active }) { + Object.assign(state.entries[path], { + active, + }); + }, + [types.TOGGLE_FILE_OPEN](state, path) { + Object.assign(state.entries[path], { + opened: !state.entries[path].opened, + }); + + if (state.entries[path].opened) { + state.openFiles.push(state.entries[path]); + } else { + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.path !== path), + }); + } + }, + [types.SET_FILE_DATA](state, { data, file }) { + Object.assign(state.entries[file.path], { + id: data.id, + blamePath: data.blame_path, + commitsPath: data.commits_path, + permalink: data.permalink, + rawPath: data.raw_path, + binary: data.binary, + renderError: data.render_error, + }); + }, + [types.SET_FILE_RAW_DATA](state, { file, raw }) { + Object.assign(state.entries[file.path], { + raw, + }); + }, + [types.UPDATE_FILE_CONTENT](state, { path, content }) { + const changed = content !== state.entries[path].raw; + + Object.assign(state.entries[path], { + content, + changed, + }); + }, + [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) { + Object.assign(state.entries[file.path], { + fileLanguage, + }); + }, + [types.SET_FILE_EOL](state, { file, eol }) { + Object.assign(state.entries[file.path], { + eol, + }); + }, + [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { + Object.assign(state.entries[file.path], { + editorRow, + editorColumn, + }); + }, + [types.DISCARD_FILE_CHANGES](state, path) { + Object.assign(state.entries[path], { + content: state.entries[path].raw, + changed: false, + }); + }, + [types.ADD_FILE_TO_CHANGED](state, path) { + Object.assign(state, { + changedFiles: state.changedFiles.concat(state.entries[path]), + }); + }, + [types.REMOVE_FILE_FROM_CHANGED](state, path) { + Object.assign(state, { + changedFiles: state.changedFiles.filter(f => f.path !== path), + }); + }, + [types.TOGGLE_FILE_CHANGED](state, { file, changed }) { + Object.assign(state.entries[file.path], { + changed, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js new file mode 100644 index 00000000000..2816562a919 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -0,0 +1,23 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_PROJECT](state, currentProjectId) { + Object.assign(state, { + currentProjectId, + }); + }, + [types.SET_PROJECT](state, { projectPath, project }) { + // Add client side properties + Object.assign(project, { + tree: [], + branches: {}, + active: true, + }); + + Object.assign(state, { + projects: Object.assign({}, state.projects, { + [projectPath]: project, + }), + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js new file mode 100644 index 00000000000..7f7e470c9bb --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -0,0 +1,38 @@ +import * as types from '../mutation_types'; + +export default { + [types.TOGGLE_TREE_OPEN](state, path) { + Object.assign(state.entries[path], { + opened: !state.entries[path].opened, + }); + }, + [types.CREATE_TREE](state, { treePath }) { + Object.assign(state, { + trees: Object.assign({}, state.trees, { + [treePath]: { + tree: [], + loading: true, + }, + }), + }); + }, + [types.SET_DIRECTORY_DATA](state, { data, treePath }) { + Object.assign(state, { + trees: Object.assign(state.trees, { + [treePath]: { + tree: data, + }, + }), + }); + }, + [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { + Object.assign(tree, { + lastCommitPath: url, + }); + }, + [types.REMOVE_ALL_CHANGES_FILES](state) { + Object.assign(state, { + changedFiles: [], + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js new file mode 100644 index 00000000000..6110f54951c --- /dev/null +++ b/app/assets/javascripts/ide/stores/state.js @@ -0,0 +1,19 @@ +export default () => ({ + currentProjectId: '', + currentBranchId: '', + changedFiles: [], + endpoints: {}, + lastCommitMsg: '', + lastCommitPath: '', + loading: false, + openFiles: [], + parentTreeUrl: '', + trees: {}, + projects: {}, + leftPanelCollapsed: false, + rightPanelCollapsed: false, + panelResizing: false, + entries: {}, + viewer: 'editor', + delayViewerUpdated: false, +}); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js new file mode 100644 index 00000000000..487ea1ead8e --- /dev/null +++ b/app/assets/javascripts/ide/stores/utils.js @@ -0,0 +1,125 @@ +export const dataStructure = () => ({ + id: '', + key: '', + type: '', + projectId: '', + branchId: '', + name: '', + url: '', + path: '', + tempFile: false, + tree: [], + loading: false, + opened: false, + active: false, + changed: false, + lastCommitPath: '', + lastCommit: { + id: '', + url: '', + message: '', + updatedAt: '', + author: '', + }, + blamePath: '', + commitsPath: '', + permalink: '', + rawPath: '', + binary: false, + html: '', + raw: '', + content: '', + parentTreeUrl: '', + renderError: false, + base64: false, + editorRow: 1, + editorColumn: 1, + fileLanguage: '', + eol: '', +}); + +export const decorateData = (entity) => { + const { + id, + projectId, + branchId, + type, + url, + name, + path, + renderError, + content = '', + tempFile = false, + active = false, + opened = false, + changed = false, + parentTreeUrl = '', + base64 = false, + + file_lock, + + } = entity; + + return { + ...dataStructure(), + id, + projectId, + branchId, + key: `${name}-${type}-${id}`, + type, + name, + url, + path, + tempFile, + opened, + active, + parentTreeUrl, + changed, + renderError, + content, + base64, + + file_lock, + + }; +}; + +export const findEntry = (tree, type, name, prop = 'name') => tree.find( + f => f.type === type && f[prop] === name, +); + +export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); + +export const setPageTitle = (title) => { + document.title = title; +}; + +export const createCommitPayload = (branch, newBranch, state, rootState) => ({ + branch, + commit_message: state.commitMessage, + actions: rootState.changedFiles.map(f => ({ + action: f.tempFile ? 'create' : 'update', + file_path: f.path, + content: f.content, + encoding: f.base64 ? 'base64' : 'text', + })), + start_branch: newBranch ? rootState.currentBranchId : undefined, +}); + +export const createNewMergeRequestUrl = (projectUrl, source, target) => + `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`; + +const sortTreesByTypeAndName = (a, b) => { + if (a.type === 'tree' && b.type === 'blob') { + return -1; + } else if (a.type === 'blob' && b.type === 'tree') { + return 1; + } + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; +}; + +export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, { + tree: entity.tree.length ? sortTree(entity.tree) : [], +})).sort(sortTreesByTypeAndName); diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js new file mode 100644 index 00000000000..e959130300b --- /dev/null +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -0,0 +1,91 @@ +import { + decorateData, + sortTree, +} from '../utils'; + +self.addEventListener('message', (e) => { + const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; + + const treeList = []; + let file; + const entries = data.reduce((acc, path) => { + const pathSplit = path.split('/'); + const blobName = pathSplit.pop().trim(); + + if (pathSplit.length > 0) { + pathSplit.reduce((pathAcc, folderName) => { + const parentFolder = acc[pathAcc[pathAcc.length - 1]]; + const folderPath = `${(parentFolder ? `${parentFolder.path}/` : '')}${folderName}`; + const foundEntry = acc[folderPath]; + + if (!foundEntry) { + const tree = decorateData({ + projectId, + branchId, + id: folderPath, + name: folderName, + path: folderPath, + url: `/${projectId}/tree/${branchId}/${folderPath}`, + type: 'tree', + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, + tempFile, + changed: tempFile, + opened: tempFile, + }); + + Object.assign(acc, { + [folderPath]: tree, + }); + + if (parentFolder) { + parentFolder.tree.push(tree); + } else { + treeList.push(tree); + } + + pathAcc.push(tree.path); + } else { + pathAcc.push(foundEntry.path); + } + + return pathAcc; + }, []); + } + + if (blobName !== '') { + const fileFolder = acc[pathSplit.join('/')]; + file = decorateData({ + projectId, + branchId, + id: path, + name: blobName, + path, + url: `/${projectId}/blob/${branchId}/${path}`, + type: 'blob', + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, + tempFile, + changed: tempFile, + content, + base64, + }); + + Object.assign(acc, { + [path]: file, + }); + + if (fileFolder) { + fileFolder.tree.push(file); + } else { + treeList.push(file); + } + } + + return acc; + }, {}); + + self.postMessage({ + entries, + treeList: sortTree(treeList), + file, + }); +}); diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 2d015ef086b..93cb83b3a4c 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -20,7 +20,7 @@ width: 100%; } - $image-widths: 250 306 394 430; + $image-widths: 80 250 306 394 430; @each $width in $image-widths { &.svg-#{$width} { img, @@ -39,12 +39,28 @@ svg { fill: currentColor; - &.s8 { @include svg-size(8px); } - &.s12 { @include svg-size(12px); } - &.s16 { @include svg-size(16px); } - &.s18 { @include svg-size(18px); } - &.s24 { @include svg-size(24px); } - &.s32 { @include svg-size(32px); } - &.s48 { @include svg-size(48px); } - &.s72 { @include svg-size(72px); } + &.s8 { + @include svg-size(8px); + } + &.s12 { + @include svg-size(12px); + } + &.s16 { + @include svg-size(16px); + } + &.s18 { + @include svg-size(18px); + } + &.s24 { + @include svg-size(24px); + } + &.s32 { + @include svg-size(32px); + } + &.s48 { + @include svg-size(48px); + } + &.s72 { + @include svg-size(72px); + } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 8265b8370f7..7a8fbfc517d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -19,6 +19,7 @@ .ide-view { display: flex; height: calc(100vh - #{$header-height}); + margin-top: 40px; color: $almost-black; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -28,6 +29,11 @@ max-width: 250px; } } + + .file-status-icon { + width: 10px; + height: 10px; + } } .ide-file-list { @@ -40,31 +46,41 @@ background: $white-normal; } - .repo-file-name { + .ide-file-name { + flex: 1; white-space: nowrap; text-overflow: ellipsis; + + svg { + vertical-align: middle; + margin-right: 2px; + } + + .loading-container { + margin-right: 4px; + display: inline-block; + } } - .unsaved-icon { - color: $indigo-700; - float: right; - font-size: smaller; - line-height: 20px; + .ide-file-changed-icon { + margin-left: auto; } - .repo-new-btn { + .ide-new-btn { display: none; - margin-top: -4px; margin-bottom: -4px; + margin-right: -8px; } &:hover { - .repo-new-btn { + .ide-new-btn { display: block; } + } - .unsaved-icon { - display: none; + &.folder { + svg { + fill: $gl-text-color-secondary; } } } @@ -79,10 +95,10 @@ } } -.multi-file-table-name, -.multi-file-table-col-commit-message { +.file-name, +.file-col-commit-message { + display: flex; overflow: visible; - max-width: 0; padding: 6px 12px; } @@ -99,21 +115,6 @@ } } -table.table tr td.multi-file-table-name { - width: 350px; - padding: 6px 12px; - - svg { - vertical-align: middle; - margin-right: 2px; - } - - .loading-container { - margin-right: 4px; - display: inline-block; - } -} - .multi-file-table-col-commit-message { white-space: nowrap; width: 50%; @@ -129,13 +130,35 @@ table.table tr td.multi-file-table-name { .multi-file-tabs { display: flex; - overflow-x: auto; background-color: $white-normal; box-shadow: inset 0 -1px $white-dark; - > li { + > ul { + display: flex; + overflow-x: auto; + } + + li { position: relative; } + + .dropdown { + display: flex; + margin-left: auto; + margin-bottom: 1px; + padding: 0 $grid-size; + border-left: 1px solid $white-dark; + background-color: $white-light; + + &.shadow { + box-shadow: 0 0 10px $dropdown-shadow-color; + } + + .btn { + margin-top: auto; + margin-bottom: auto; + } + } } .multi-file-tab { @@ -160,20 +183,32 @@ table.table tr td.multi-file-table-name { position: absolute; right: 8px; top: 50%; + width: 16px; + height: 16px; padding: 0; background: none; border: 0; - font-size: $gl-font-size; - color: $gray-darkest; + border-radius: $border-radius-default; + color: $theme-gray-900; transform: translateY(-50%); - &:not(.modified):hover, - &:not(.modified):focus { - color: $hint-color; + svg { + position: relative; + top: -1px; } - &.modified { - color: $indigo-700; + &:hover { + background-color: $theme-gray-200; + } + + &:focus { + background-color: $blue-500; + color: $white-light; + outline: 0; + + svg { + fill: currentColor; + } } } @@ -192,6 +227,70 @@ table.table tr td.multi-file-table-name { .vertical-center { min-height: auto; } + + .monaco-editor .lines-content .cigr { + display: none; + } + + .monaco-diff-editor.vs { + .editor.modified { + box-shadow: none; + } + + .diagonal-fill { + display: none !important; + } + + .diffOverview { + background-color: $white-light; + border-left: 1px solid $white-dark; + cursor: ns-resize; + } + + .diffViewport { + display: none; + } + + .char-insert { + background-color: $line-added-dark; + } + + .char-delete { + background-color: $line-removed-dark; + } + + .line-numbers { + color: $black-transparent; + } + + .view-overlays { + .line-insert { + background-color: $line-added; + } + + .line-delete { + background-color: $line-removed; + } + } + + .margin { + background-color: $gray-light; + border-right: 1px solid $white-normal; + + .line-insert { + border-right: 1px solid $line-added-dark; + } + + .line-delete { + border-right: 1px solid $line-removed-dark; + } + } + + .margin-view-overlays .insert-sign, + .margin-view-overlays .delete-sign { + opacity: 0.4; + } + } } .multi-file-editor-holder { @@ -252,7 +351,7 @@ table.table tr td.multi-file-table-name { display: flex; position: relative; flex-direction: column; - width: 290px; + width: 340px; padding: 0; background-color: $gray-light; padding-right: 3px; @@ -350,6 +449,11 @@ table.table tr td.multi-file-table-name { flex: 1; } +.multi-file-commit-empty-state-container { + align-items: center; + justify-content: center; +} + .multi-file-commit-panel-header { display: flex; align-items: center; @@ -376,7 +480,7 @@ table.table tr td.multi-file-table-name { .multi-file-commit-panel-header-title { display: flex; flex: 1; - padding: $gl-btn-padding; + padding: 0 $gl-btn-padding; svg { margin-right: $gl-btn-padding; @@ -390,12 +494,34 @@ table.table tr td.multi-file-table-name { .multi-file-commit-list { flex: 1; overflow: auto; - padding: $gl-padding; + padding: $gl-padding 0; + min-height: 60px; } .multi-file-commit-list-item { display: flex; + padding: 0; align-items: center; + + .multi-file-discard-btn { + display: none; + margin-left: auto; + color: $gl-link-color; + padding: 0 2px; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + &:hover { + background: $white-normal; + + .multi-file-discard-btn { + display: block; + } + } } .multi-file-addition { @@ -414,29 +540,58 @@ table.table tr td.multi-file-table-name { margin-left: auto; margin-right: auto; } + + .file-status-icon { + width: 10px; + height: 10px; + margin-left: 3px; + } } .multi-file-commit-list-path { + padding: $grid-size / 2; + padding-left: $gl-padding; + background: none; + border: 0; + text-align: left; + width: 100%; + min-width: 0; + + svg { + min-width: 16px; + vertical-align: middle; + display: inline-block; + } + + &:hover, + &:focus { + outline: 0; + } +} + +.multi-file-commit-list-file-path { @include str-truncated(100%); + + &:hover { + text-decoration: underline; + } + + &:active { + text-decoration: none; + } } .multi-file-commit-form { padding: $gl-padding; border-top: 1px solid $white-dark; -} - -.multi-file-commit-fieldset { - display: flex; - align-items: center; - padding-bottom: 12px; .btn { - flex: 1; + font-size: $gl-font-size; } } .multi-file-commit-message.form-control { - height: 80px; + height: 160px; resize: none; } @@ -468,7 +623,7 @@ table.table tr td.multi-file-table-name { top: 0; width: 100px; height: 1px; - background-color: rgba($red-500, .5); + background-color: rgba($red-500, 0.5); } } } @@ -487,7 +642,7 @@ table.table tr td.multi-file-table-name { justify-content: center; } -.repo-new-btn { +.ide-new-btn { .dropdown-toggle svg { margin-top: -2px; margin-bottom: 2px; @@ -505,36 +660,39 @@ table.table tr td.multi-file-table-name { } } -.ide.nav-only { - .flash-container { - margin-top: $header-height; - margin-bottom: 0; - } - - .alert-wrapper .flash-container .flash-alert:last-child, - .alert-wrapper .flash-container .flash-notice:last-child { - margin-bottom: 0; - } +.ide { + overflow: hidden; - .content { - margin-top: $header-height; - } + &.nav-only { + .flash-container { + margin-top: $header-height; + margin-bottom: 0; + } - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $context-header-height}); - } + .alert-wrapper .flash-container .flash-alert:last-child, + .alert-wrapper .flash-container .flash-notice:last-child { + margin-bottom: 0; + } - &.flash-shown { - .content { - margin-top: 0; + .content-wrapper { + margin-top: $header-height; + padding-bottom: 0; } - .ide-view { - height: calc(100vh - #{$header-height + $flash-height}); + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $flash-height}); + } } - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $flash-height + $context-header-height}); + .projects-sidebar { + .multi-file-commit-panel-inner-scroll { + flex: 1; + } } } } @@ -544,34 +702,28 @@ table.table tr td.multi-file-table-name { margin-top: #{$header-height + $performance-bar-height}; } - .content { + .content-wrapper { margin-top: #{$header-height + $performance-bar-height}; + padding-bottom: 0; } .ide-view { height: calc(100vh - #{$header-height + $performance-bar-height}); } - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $performance-bar-height + 60}); - } - &.flash-shown { - .content { + .content-wrapper { margin-top: 0; } .ide-view { - height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height}); - } - - .multi-file-commit-panel .multi-file-commit-panel-inner-scroll { - max-height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height + $context-header-height}); + height: calc( + 100vh - #{$header-height + $performance-bar-height + $flash-height} + ); } } } - .dragHandle { position: absolute; top: 0; @@ -587,3 +739,44 @@ table.table tr td.multi-file-table-name { left: 0; } } + +.ide-commit-radios { + label { + font-weight: normal; + } + + .help-block { + margin-top: 0; + line-height: 0; + } +} + +.ide-commit-new-branch { + margin-left: 25px; +} + +.ide-external-links { + p { + margin: 0; + } +} + +.ide-sidebar-link { + padding: $gl-padding-8 $gl-padding; + background: $indigo-700; + color: $white-light; + text-decoration: none; + display: flex; + align-items: center; + + &:focus, + &:hover { + color: $white-light; + text-decoration: underline; + background: $indigo-500; + } + + &:active { + background: $indigo-800; + } +} diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb new file mode 100644 index 00000000000..1ff25a45398 --- /dev/null +++ b/app/controllers/ide_controller.rb @@ -0,0 +1,6 @@ +class IdeController < ApplicationController + layout 'nav_only' + + def index + end +end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb new file mode 100644 index 00000000000..f090ae71269 --- /dev/null +++ b/app/helpers/ide_helper.rb @@ -0,0 +1,14 @@ +module IdeHelper + def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + return unless blob = readable_blob(options, path, project, ref) + + common_classes = "btn js-edit-ide #{options[:extra_class]}" + + edit_button_tag(blob, + common_classes, + _('Web IDE'), + ide_edit_path(project, ref, path, options), + project, + ref) + end +end diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml new file mode 100644 index 00000000000..e0e8fe548d0 --- /dev/null +++ b/app/views/ide/index.html.haml @@ -0,0 +1,12 @@ +- @body_class = 'ide' +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'ide', force_same_domain: true + +#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), + "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), + "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } } + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 06bce52e709..67613949b7d 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -76,4 +76,8 @@ = render 'projects/find_file_link' + = succeed " " do + = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do + = _('Web IDE') + = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/config/routes.rb b/config/routes.rb index 8769f433c39..52726f94753 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,9 @@ Rails.application.routes.draw do # UserCallouts resources :user_callouts, only: [:create] + + get 'ide' => 'ide#index' + get 'ide/*vueroute' => 'ide#index', format: false end # Koding route diff --git a/config/webpack.config.js b/config/webpack.config.js index 3403c0c207d..f5fb7de6176 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -9,12 +9,14 @@ const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; const CopyWebpackPlugin = require('copy-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin'); const NameAllModulesPlugin = require('name-all-modules-plugin'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') + .BundleAnalyzerPlugin; const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); const ROOT_PATH = path.resolve(__dirname, '..'); const IS_PRODUCTION = process.env.NODE_ENV === 'production'; -const IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1; +const IS_DEV_SERVER = + process.argv.join(' ').indexOf('webpack-dev-server') !== -1; const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; @@ -27,10 +29,10 @@ let watchAutoEntries = []; function generateEntries() { // generate automatic entry points const autoEntries = {}; - const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') }); - watchAutoEntries = [ - path.join(ROOT_PATH, 'app/assets/javascripts/pages/'), - ]; + const pageEntries = glob.sync('pages/**/index.js', { + cwd: path.join(ROOT_PATH, 'app/assets/javascripts'), + }); + watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')]; function generateAutoEntries(path, prefix = '.') { const chunkPath = path.replace(/\/index\.js$/, ''); @@ -38,15 +40,16 @@ function generateEntries() { autoEntries[chunkName] = `${prefix}/${path}`; } - pageEntries.forEach(( path ) => generateAutoEntries(path)); + pageEntries.forEach(path => generateAutoEntries(path)); autoEntriesCount = Object.keys(autoEntries).length; const manualEntries = { - common: './commons/index.js', - main: './main.js', - raven: './raven/index.js', - webpack_runtime: './webpack.js', + common: './commons/index.js', + main: './main.js', + raven: './raven/index.js', + webpack_runtime: './webpack.js', + ide: './ide/index.js', }; return Object.assign(manualEntries, autoEntries); @@ -60,8 +63,12 @@ const config = { output: { path: path.join(ROOT_PATH, 'public/assets/webpack'), publicPath: '/assets/webpack/', - filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js', - chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js', + filename: IS_PRODUCTION + ? '[name].[chunkhash].bundle.js' + : '[name].bundle.js', + chunkFilename: IS_PRODUCTION + ? '[name].[chunkhash].chunk.js' + : '[name].chunk.js', }, module: { @@ -90,8 +97,8 @@ const config = { { loader: 'worker-loader', options: { - inline: true - } + inline: true, + }, }, { loader: 'babel-loader' }, ], @@ -102,7 +109,7 @@ const config = { loader: 'file-loader', options: { name: '[name].[hash].[ext]', - } + }, }, { test: /katex.css$/, @@ -112,8 +119,8 @@ const config = { { loader: 'css-loader', options: { - name: '[name].[hash].[ext]' - } + name: '[name].[hash].[ext]', + }, }, ], }, @@ -123,15 +130,18 @@ const config = { loader: 'file-loader', options: { name: '[name].[hash].[ext]', - } + }, }, { test: /monaco-editor\/\w+\/vs\/loader\.js$/, use: [ { loader: 'exports-loader', options: 'l.global' }, - { loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' }, + { + loader: 'imports-loader', + options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined', + }, ], - } + }, ], noParse: [/monaco-editor\/\w+\/vs\//], @@ -149,10 +159,10 @@ const config = { source: false, chunks: false, modules: false, - assets: true + assets: true, }); return JSON.stringify(stats, null, 2); - } + }, }), // prevent pikaday from including moment.js @@ -169,7 +179,7 @@ const config = { new NameAllModulesPlugin(), // assign deterministic chunk ids - new webpack.NamedChunksPlugin((chunk) => { + new webpack.NamedChunksPlugin(chunk => { if (chunk.name) { return chunk.name; } @@ -186,9 +196,12 @@ const config = { const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages'); if (m.resource.indexOf(pagesBase) === 0) { - moduleNames.push(path.relative(pagesBase, m.resource) - .replace(/\/index\.[a-z]+$/, '') - .replace(/\//g, '__')); + moduleNames.push( + path + .relative(pagesBase, m.resource) + .replace(/\/index\.[a-z]+$/, '') + .replace(/\//g, '__'), + ); } else { moduleNames.push(path.relative(m.context, m.resource)); } @@ -196,7 +209,8 @@ const config = { chunk.forEachModule(collectModuleNames); - const hash = crypto.createHash('sha256') + const hash = crypto + .createHash('sha256') .update(moduleNames.join('_')) .digest('hex'); @@ -214,10 +228,17 @@ const config = { // copy pre-compiled vendor libraries verbatim new CopyWebpackPlugin([ { - from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`), + from: path.join( + ROOT_PATH, + `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`, + ), to: 'monaco-editor/vs', transform: function(content, path) { - if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) { + if ( + /\.js$/.test(path) && + !/worker/i.test(path) && + !/typescript/i.test(path) + ) { return ( '(function(){\n' + 'var define = this.define, require = this.require;\n' + @@ -227,23 +248,23 @@ const config = { ); } return content; - } - } + }, + }, ]), ], resolve: { extensions: ['.js'], alias: { - '~': path.join(ROOT_PATH, 'app/assets/javascripts'), - 'emojis': path.join(ROOT_PATH, 'fixtures/emojis'), - 'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'), - 'icons': path.join(ROOT_PATH, 'app/views/shared/icons'), - 'images': path.join(ROOT_PATH, 'app/assets/images'), - 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), - 'vue$': 'vue/dist/vue.esm.js', - 'spec': path.join(ROOT_PATH, 'spec/javascripts'), - } + '~': path.join(ROOT_PATH, 'app/assets/javascripts'), + emojis: path.join(ROOT_PATH, 'fixtures/emojis'), + empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'), + icons: path.join(ROOT_PATH, 'app/views/shared/icons'), + images: path.join(ROOT_PATH, 'app/assets/images'), + vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'), + vue$: 'vue/dist/vue.esm.js', + spec: path.join(ROOT_PATH, 'spec/javascripts'), + }, }, // sqljs requires fs @@ -258,14 +279,14 @@ if (IS_PRODUCTION) { new webpack.NoEmitOnErrorsPlugin(), new webpack.LoaderOptionsPlugin({ minimize: true, - debug: false + debug: false, }), new webpack.optimize.UglifyJsPlugin({ - sourceMap: true + sourceMap: true, }), new webpack.DefinePlugin({ - 'process.env': { NODE_ENV: JSON.stringify('production') } - }) + 'process.env': { NODE_ENV: JSON.stringify('production') }, + }), ); // compression can require a lot of compute time and is disabled in CI @@ -283,7 +304,7 @@ if (IS_DEV_SERVER) { headers: { 'Access-Control-Allow-Origin': '*' }, stats: 'errors-only', hot: DEV_SERVER_LIVERELOAD, - inline: DEV_SERVER_LIVERELOAD + inline: DEV_SERVER_LIVERELOAD, }; config.plugins.push( // watch node_modules for changes if we encounter a missing module compile error @@ -299,12 +320,14 @@ if (IS_DEV_SERVER) { ]; // report our auto-generated bundle count - console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`); + console.log( + `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`, + ); callback(); - }) + }); }, - } + }, ); if (DEV_SERVER_LIVERELOAD) { config.plugins.push(new webpack.HotModuleReplacementPlugin()); @@ -319,7 +342,7 @@ if (WEBPACK_REPORT) { openAnalyzer: false, reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'), statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'), - }) + }), ); } diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb new file mode 100644 index 00000000000..d96c7e655ba --- /dev/null +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +feature 'Multi-file editor new directory', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates directory in current directory' do + find('.add-to-tree').click + + click_link('New directory') + + page.within('.modal') do + find('.form-control').set('folder name') + + click_button('Create directory') + end + + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal-dialog') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('folder name') + end +end diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb new file mode 100644 index 00000000000..a4cbd5cf766 --- /dev/null +++ b/spec/features/projects/tree/create_file_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +feature 'Multi-file editor new file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_path(project) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates file in current directory' do + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('file name') + end +end diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb new file mode 100644 index 00000000000..8e53ae15700 --- /dev/null +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +feature 'Multi-file editor upload file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') } + let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'uploads text file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', txt_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt') + expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline)) + end + + it 'uploads image file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', img_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'dk.png') + expect(page).not_to have_selector('.monaco-editor') + end +end diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js new file mode 100644 index 00000000000..8f796b2f7f5 --- /dev/null +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import changedFileIcon from 'ee/ide/components/changed_file_icon.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE changed file icon', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(changedFileIcon); + + vm = createComponent(component, { + file: { + tempFile: false, + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('changedIcon', () => { + it('equals file-modified when not a temp file', () => { + expect(vm.changedIcon).toBe('file-modified'); + }); + + it('equals file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIcon).toBe('file-addition'); + }); + }); + + describe('changedIconClass', () => { + it('includes multi-file-modified when not a temp file', () => { + expect(vm.changedIconClass).toContain('multi-file-modified'); + }); + + it('includes multi-file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIconClass).toContain('multi-file-addition'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js new file mode 100644 index 00000000000..47a9007a8d0 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import commitActions from 'ee/ide/components/commit_sidebar/actions.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar actions', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(commitActions); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.currentBranchId = 'master'; + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 groups', () => { + expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3); + }); + + it('renders current branch text', () => { + expect(vm.$el.textContent).toContain('Commit to master branch'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js new file mode 100644 index 00000000000..f6789bc861f --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import listCollapsed from 'ee/ide/components/commit_sidebar/list_collapsed.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list collapsed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(listCollapsed); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.changedFiles.push(file('file1'), file('file2')); + vm.$store.state.changedFiles[0].tempFile = true; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders added & modified files count', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js new file mode 100644 index 00000000000..543299950ea --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import listItem from 'ee/ide/components/commit_sidebar/list_item.vue'; +import router from 'ee/ide/ide_router'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list item', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(listItem); + + f = file('test-file'); + + vm = mountComponent(Component, { + file: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file path', () => { + expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); + }); + + it('calls discardFileChanges when clicking discard button', () => { + spyOn(vm, 'discardFileChanges'); + + vm.$el.querySelector('.multi-file-discard-btn').click(); + + expect(vm.discardFileChanges).toHaveBeenCalled(); + }); + + it('opens a closed file in the editor when clicking the file path', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.openFileInEditor).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + }); + + it('calls updateViewer with diff when clicking file', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + }); + + describe('computed', () => { + describe('iconName', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconName).toBe('file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconName).toBe('file-addition'); + }); + }); + + describe('iconClass', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconClass).toContain('multi-file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconClass).toContain('multi-file-addition'); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js new file mode 100644 index 00000000000..f02d055e38c --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import commitSidebarList from 'ee/ide/components/commit_sidebar/list.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(commitSidebarList); + + vm = createComponentWithStore(Component, store, { + title: 'Staged', + fileList: [], + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with a list of files', () => { + beforeEach((done) => { + const f = file('file name'); + f.changed = true; + vm.fileList.push(f); + + Vue.nextTick(done); + }); + + it('renders list', () => { + expect(vm.$el.querySelectorAll('li').length).toBe(1); + }); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('hides list', () => { + expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); + expect(vm.$el.querySelector('.help-block')).toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..1058cc28de2 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import radioGroup from 'ee/ide/components/commit_sidebar/radio_group.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar radio group', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '2'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('uses label if present', () => { + expect(vm.$el.textContent).toContain('test'); + }); + + it('uses slot if label is not present', (done) => { + vm.$destroy(); + + vm = new Vue({ + components: { + radioGroup, + }, + store, + template: ` + + Testing slot + + `, + }); + + vm.$mount(); + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('Testing slot'); + + done(); + }); + }); + + it('updates store when changing radio button', (done) => { + vm.$el.querySelector('input').dispatchEvent(new Event('change')); + + Vue.nextTick(() => { + expect(store.state.commit.commitAction).toBe('1'); + + done(); + }); + }); + + it('renders helpText tooltip', (done) => { + vm.helpText = 'help text'; + + Vue.nextTick(() => { + const help = vm.$el.querySelector('.help-block'); + + expect(help).not.toBeNull(); + expect(help.getAttribute('data-original-title')).toBe('help text'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach((done) => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + showInput: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders input box when commitAction matches value', () => { + expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + }); + + it('hides input when commitAction doesnt match value', (done) => { + store.state.commit.commitAction = '2'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.form-control')).toBeNull(); + done(); + }); + }); + + it('updates branch name in store on input', (done) => { + const input = vm.$el.querySelector('.form-control'); + input.value = 'testing-123'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick(() => { + expect(store.state.commit.newBranchName).toBe('testing-123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js new file mode 100644 index 00000000000..9fa2e947db2 --- /dev/null +++ b/spec/javascripts/ide/components/ide_context_bar_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ideContextBar from 'ee/ide/components/ide_context_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; + +describe('Multi-file editor right context bar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideContextBar); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('adds collapsed class', () => { + expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js new file mode 100644 index 00000000000..b8da6747653 --- /dev/null +++ b/spec/javascripts/ide/components/ide_external_links_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import ideExternalLinks from 'ee/ide/components/ide_external_links.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('ide external links component', () => { + let vm; + let fakeReferrer; + let Component; + + const fakeProjectUrl = '/project/'; + + beforeEach(() => { + Component = Vue.extend(ideExternalLinks); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('goBackUrl', () => { + it('renders the Go Back link with the referrer when present', () => { + fakeReferrer = '/example/README.md'; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeReferrer); + }); + + it('renders the Go Back link with the project url when referrer is not present', () => { + fakeReferrer = ''; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeProjectUrl); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js new file mode 100644 index 00000000000..e7188490f64 --- /dev/null +++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import ideRepoTree from 'ee/ide/components/ide_repo_tree.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('IdeRepoTree', () => { + let vm; + let tree; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(ideRepoTree); + + tree = { + tree: [file()], + loading: false, + }; + + vm = createComponent(IdeRepoTree, { + tree, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.loading-file')).toBeNull(); + expect(vm.$el.querySelector('.file')).not.toBeNull(); + }); + + it('renders 3 loading files if tree is loading', (done) => { + tree.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toEqual(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..74afca280d1 --- /dev/null +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ideSidebar from 'ee/ide/components/ide_side_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); + }); + + it('renders loading icon component', (done) => { + vm.$store.state.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js new file mode 100644 index 00000000000..7f8dcd9049f --- /dev/null +++ b/spec/javascripts/ide/components/ide_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ide from 'ee/ide/components/ide.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('ide component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ide); + + vm = createComponentWithStore(Component, store, { + emptyStateSvgPath: 'svg', + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('does not render panel right when no files open', () => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + }); + + it('renders panel right when files are open', (done) => { + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [file()], + }; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..cba27f94833 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import newDropdown from 'ee/ide/components/new_dropdown/index.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.path = ''; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders new file, upload and new directory links', () => { + expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe( + 'Upload file', + ); + expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe( + 'New directory', + ); + }); + + describe('createNewItem', () => { + it('sets modalType to blob when new file is clicked', () => { + vm.$el.querySelectorAll('a')[0].click(); + + expect(vm.modalType).toBe('blob'); + }); + + it('sets modalType to tree when new directory is clicked', () => { + vm.$el.querySelectorAll('a')[2].click(); + + expect(vm.modalType).toBe('tree'); + }); + + it('opens modal when link is clicked', done => { + vm.$el.querySelectorAll('a')[0].click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + + done(); + }); + }); + }); + + describe('hideModal', () => { + beforeAll(done => { + vm.openModal = true; + Vue.nextTick(done); + }); + + it('closes modal after toggling', done => { + vm.hideModal(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.modal')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..1a9c96c64da --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import modal from 'ee/ide/components/new_dropdown/modal.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + ['tree', 'blob'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + vm = createComponent(Component, { + type, + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + }); + + it(`sets modal title as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`); + }); + + it(`sets button label as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`); + }); + + it(`sets form label as ${type}`, () => { + const title = type === 'tree' ? 'Directory' : 'File'; + + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); + }); + + describe('createEntryInStore', () => { + it('$emits create', () => { + spyOn(vm, '$emit'); + + vm.createEntryInStore(); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + branchId: 'master', + name: 'testing', + type, + }); + }); + }); + }); + }); + + it('focuses field on mount', () => { + document.body.innerHTML += '
'; + + vm = createComponent(Component, { + type: 'tree', + branchId: 'master', + path: '', + }, '.js-test'); + + expect(document.activeElement).toBe(vm.$refs.fieldName); + + vm.$el.remove(); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..766e8b72360 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,87 @@ +import Vue from 'vue'; +import upload from 'ee/ide/components/new_dropdown/upload.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + + spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('readFile', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText'); + spyOn(FileReader.prototype, 'readAsDataURL'); + }); + + it('calls readAsText for text files', () => { + const file = { + type: 'text/html', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file); + }); + + it('calls readAsDataURL for non-text files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const target = { + result: 'content', + }; + const binaryTarget = { + result: 'base64,base64content', + }; + const file = { + name: 'file', + }; + + it('creates new file', () => { + vm.createFile(target, file, true); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: target.result, + base64: false, + }); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, file, false); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js new file mode 100644 index 00000000000..8090e3664e0 --- /dev/null +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -0,0 +1,154 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import repoCommitSection from 'ee/ide/components/repo_commit_section.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoCommitSection', () => { + let vm; + + function createComponent() { + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'commitsvg', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.currentBranchId = 'master'; + vm.$store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + + vm.$store.state.rightPanelCollapsed = false; + vm.$store.state.currentBranch = 'master'; + vm.$store.state.changedFiles = [file('file1'), file('file2')]; + vm.$store.state.changedFiles.forEach(f => Object.assign(f, { + changed: true, + content: 'testing', + })); + + return vm.$mount(); + } + + beforeEach((done) => { + vm = createComponent(); + + spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), + })); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('empty Stage', () => { + it('renders no changes text', () => { + resetStore(vm.$store); + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'nochangessvg', + committedStateSvgPath: 'svg', + }).$mount(); + + expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); + expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg'); + }); + }); + + it('renders a commit section', () => { + const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')]; + const submitCommit = vm.$el.querySelector('form .btn'); + + expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); + expect(changedFileElements.length).toEqual(2); + + changedFileElements.forEach((changedFile, i) => { + expect(changedFile.textContent.trim()).toContain(vm.$store.state.changedFiles[i].path); + }); + + expect(submitCommit.disabled).toBeTruthy(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); + }); + + it('updates commitMessage in store on input', (done) => { + const textarea = vm.$el.querySelector('textarea'); + + textarea.value = 'testing commit message'; + + textarea.dispatchEvent(new Event('input')); + + getSetTimeoutPromise() + .then(() => { + expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + + describe('discard draft button', () => { + it('hidden when commitMessage is empty', () => { + expect(vm.$el.querySelector('.multi-file-commit-form .btn-default')).toBeNull(); + }); + + it('resets commitMessage when clicking discard button', (done) => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-default').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when submitting', () => { + beforeEach(() => { + spyOn(vm, 'commitChanges'); + }); + + it('calls commitChanges', (done) => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-success').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.commitChanges).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js new file mode 100644 index 00000000000..cda88623497 --- /dev/null +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoEditor from 'ee/ide/components/repo_editor.vue'; +import monacoLoader from 'ee/ide/monaco_loader'; +import Editor from 'ee/ide/lib/editor'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoEditor', () => { + let vm; + + beforeEach((done) => { + const f = file(); + const RepoEditor = Vue.extend(repoEditor); + + vm = createComponentWithStore(RepoEditor, store, { + file: f, + }); + + f.active = true; + f.tempFile = true; + f.html = 'testing'; + vm.$store.state.openFiles.push(f); + vm.$store.state.entries[f.path] = f; + vm.monaco = true; + + vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + + Editor.editorInstance.modelManager.dispose(); + }); + + it('renders an ide container', (done) => { + Vue.nextTick(() => { + expect(vm.shouldHideEditor).toBeFalsy(); + + done(); + }); + }); + + describe('when open file is binary and not raw', () => { + beforeEach((done) => { + vm.file.binary = true; + + vm.$nextTick(done); + }); + + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + + it('shows activeFile html', () => { + expect(vm.$el.textContent).toContain('testing'); + }); + }); + + describe('createEditorInstance', () => { + it('calls createInstance when viewer is editor', (done) => { + spyOn(vm.editor, 'createInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is diff', (done) => { + vm.$store.state.viewer = 'diff'; + + spyOn(vm.editor, 'createDiffInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('setupEditor', () => { + it('creates new model', () => { + spyOn(vm.editor, 'createModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file); + expect(vm.model).not.toBeNull(); + }); + + it('attaches model to editor', () => { + spyOn(vm.editor, 'attachModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); + }); + + it('adds callback methods', () => { + spyOn(vm.editor, 'onPositionChange').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.onPositionChange).toHaveBeenCalled(); + expect(vm.model.events.size).toBe(1); + }); + + it('updates state when model content changed', (done) => { + vm.model.setValue('testing 123'); + + setTimeout(() => { + expect(vm.file.content).toBe('testing 123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js new file mode 100644 index 00000000000..13b452b1936 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import repoFileButtons from 'ee/ide/components/repo_file_buttons.vue'; +import createVueComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFileButtons', () => { + const activeFile = file(); + let vm; + + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + + return createVueComponent(RepoFileButtons, { + file: activeFile, + }); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => { + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.textContent.trim()).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.textContent.trim()).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.textContent.trim()).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js new file mode 100644 index 00000000000..3bd871544ea --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoFile from 'ee/ide/components/repo_file.vue'; +import router from 'ee/ide/ide_router'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFile', () => { + let vm; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + vm = createComponentWithStore(RepoFile, store, propsData); + + vm.$mount(); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders link, icon and name', () => { + createComponent({ + file: file('t4'), + level: 0, + }); + + const name = vm.$el.querySelector('.ide-file-name'); + + expect(name.href).toMatch(''); + expect(name.textContent.trim()).toEqual(vm.file.name); + }); + + it('fires clickFile when the link is clicked', done => { + spyOn(router, 'push'); + createComponent({ + file: file('t3'), + level: 0, + }); + + vm.$el.querySelector('.file-name').click(); + + setTimeout(() => { + expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`); + + done(); + }); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + createComponent({ + file: f, + level: 0, + }); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset + .originalTitle, + ).toContain('Locked by testuser'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js new file mode 100644 index 00000000000..dd267654289 --- /dev/null +++ b/spec/javascripts/ide/components/repo_loading_file_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoLoadingFile from 'ee/ide/components/repo_loading_file.vue'; +import { resetStore } from '../helpers'; + +describe('RepoLoadingFile', () => { + let vm; + + function createComponent() { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + store, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy(); + }); + } + + function assertColumns(columns) { + columns.forEach((column) => { + const container = column.querySelector('.animation-container'); + const lines = [...container.querySelectorAll(':scope > div')]; + + expect(container).toBeTruthy(); + expect(lines.length).toEqual(6); + assertLines(lines); + }); + } + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 columns of animated LoC', () => { + vm = createComponent(); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', (done) => { + vm = createComponent(); + vm.$store.state.leftPanelCollapsed = true; + vm.$store.state.openFiles.push('test'); + + vm.$nextTick(() => { + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js new file mode 100644 index 00000000000..c3246cd1f1f --- /dev/null +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoTab from 'ee/ide/components/repo_tab.vue'; +import router from 'ee/ide/ide_router'; +import { file, resetStore } from '../helpers'; + +describe('RepoTab', () => { + let vm; + + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + store, + propsData, + }).$mount(); + } + + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a close link and a name link', () => { + vm = createComponent({ + tab: file(), + }); + vm.$store.state.openFiles.push(vm.tab); + const close = vm.$el.querySelector('.multi-file-tab-close'); + const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); + + expect(close.innerHTML).toContain('#close'); + expect(name.textContent.trim()).toEqual(vm.tab.name); + }); + + it('fires clickFile when the link is clicked', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'clickFile'); + + vm.$el.click(); + + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'closeFile'); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); + }); + + it('changes icon on hover', (done) => { + const tab = file(); + tab.changed = true; + vm = createComponent({ + tab, + }); + + vm.$el.dispatchEvent(new Event('mouseover')); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).toBeNull(); + + vm.$el.dispatchEvent(new Event('mouseout')); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + vm = createComponent({ + tab: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain('Locked by testuser'); + }); + }); + + describe('methods', () => { + describe('closeTab', () => { + it('closes tab if file has changed', (done) => { + const tab = file(); + tab.changed = true; + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.changedFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + expect(vm.$store.state.changedFiles.length).toBe(1); + + done(); + }); + }); + + it('closes tab when clicking close btn', (done) => { + const tab = file('lose'); + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js new file mode 100644 index 00000000000..40834f230a8 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; +import repoTabs from 'ee/ide/components/repo_tabs.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoTabs', () => { + const openedFiles = [file('open1'), file('open2')]; + const RepoTabs = Vue.extend(repoTabs); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a list of tabs', done => { + vm = createComponent(RepoTabs, { + files: openedFiles, + viewer: 'editor', + hasChanges: false, + }); + openedFiles[0].active = true; + + vm.$nextTick(() => { + const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; + + expect(tabs.length).toEqual(2); + expect(tabs[0].classList.contains('active')).toEqual(true); + expect(tabs[1].classList.contains('active')).toEqual(false); + + done(); + }); + }); + + describe('updated', () => { + it('sets showShadow as true when scroll width is larger than width', done => { + const el = document.createElement('div'); + el.innerHTML = '
'; + document.body.appendChild(el); + + const style = document.createElement('style'); + style.innerText = ` + .multi-file-tabs { + width: 100px; + } + + .multi-file-tabs .list-unstyled { + display: flex; + overflow-x: auto; + } + `; + document.head.appendChild(style); + + vm = createComponent( + RepoTabs, + { + files: [], + viewer: 'editor', + hasChanges: false, + }, + '#test-app', + ); + + vm + .$nextTick() + .then(() => { + expect(vm.showShadow).toEqual(false); + + vm.files = openedFiles; + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.showShadow).toEqual(true); + + style.remove(); + el.remove(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js new file mode 100644 index 00000000000..67f9eaff44a --- /dev/null +++ b/spec/javascripts/ide/helpers.js @@ -0,0 +1,21 @@ +import { decorateData } from 'ee/ide/stores/utils'; +import state from 'ee/ide/stores/state'; +import commitState from 'ee/ide/stores/modules/commit/state'; + +export const resetStore = (store) => { + const newState = { + ...state(), + commit: commitState(), + }; + store.replaceState(newState); +}; + +export const file = (name = 'name', id = name, type = '') => decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + lastCommit: {}, +}); diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js new file mode 100644 index 00000000000..677986aff91 --- /dev/null +++ b/spec/javascripts/ide/lib/common/disposable_spec.js @@ -0,0 +1,44 @@ +import Disposable from 'ee/ide/lib/common/disposable'; + +describe('Multi-file editor library disposable class', () => { + let instance; + let disposableClass; + + beforeEach(() => { + instance = new Disposable(); + + disposableClass = { + dispose: jasmine.createSpy('dispose'), + }; + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('add', () => { + it('adds disposable classes', () => { + instance.add(disposableClass); + + expect(instance.disposers.size).toBe(1); + }); + }); + + describe('dispose', () => { + beforeEach(() => { + instance.add(disposableClass); + }); + + it('calls dispose on all cached disposers', () => { + instance.dispose(); + + expect(disposableClass.dispose).toHaveBeenCalled(); + }); + + it('clears cached disposers', () => { + instance.dispose(); + + expect(instance.disposers.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..7a1fab0f74d --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -0,0 +1,123 @@ +/* global monaco */ +import eventHub from 'ee/ide/eventhub'; +import monacoLoader from 'ee/ide/monaco_loader'; +import ModelManager from 'ee/ide/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = new ModelManager(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + instance.addModel(file('path-name')); + + expect(instance.models.keys().next().value).toBe('path-name'); + }); + + it('adds model into disposable', () => { + spyOn(instance.disposable, 'add').and.callThrough(); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + spyOn(instance.models, 'get').and.callThrough(); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + + it('adds eventHub listener', () => { + const f = file(); + spyOn(eventHub, '$on').and.callThrough(); + + instance.addModel(f); + + expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + instance.addModel(file('path-name')); + + expect(instance.hasCachedModel('path-name')).toBeTruthy(); + }); + }); + + describe('getModel', () => { + it('returns cached model', () => { + instance.addModel(file('path-name')); + + expect(instance.getModel('path-name')).not.toBeNull(); + }); + }); + + describe('removeCachedModel', () => { + let f; + + beforeEach(() => { + f = file(); + + instance.addModel(f); + }); + + it('clears cached model', () => { + instance.removeCachedModel(f); + + expect(instance.models.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + instance.removeCachedModel(f); + + expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js new file mode 100644 index 00000000000..dd9e4946883 --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -0,0 +1,107 @@ +/* global monaco */ +import eventHub from 'ee/ide/eventhub'; +import monacoLoader from 'ee/ide/monaco_loader'; +import Model from 'ee/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach((done) => { + spyOn(eventHub, '$on').and.callThrough(); + + monacoLoader(['vs/editor/editor.main'], () => { + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + }); + + it('adds eventHub listener', () => { + expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe('path'); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('setValue', () => { + it('updates models value', () => { + model.setValue('testing 123'); + + expect(model.getModel().getValue()).toBe('testing 123'); + }); + }); + + describe('onChange', () => { + it('caches event by path', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + expect(model.events.keys().next().value).toBe('path'); + }); + + it('calls callback on change', (done) => { + const spy = jasmine.createSpy(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setTimeout(() => { + expect(spy).toHaveBeenCalledWith(model, jasmine.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(model.disposable, 'dispose').and.callThrough(); + + model.dispose(); + + expect(model.disposable.dispose).toHaveBeenCalled(); + }); + + it('clears events', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + + model.dispose(); + + expect(model.events.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + model.dispose(); + + expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..63e4282d4df --- /dev/null +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -0,0 +1,120 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import DecorationsController from 'ee/ide/lib/decorations/controller'; +import Model from 'ee/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + editorInstance.dispose(); + controller.dispose(); + }); + + describe('getAllDecorationsForModel', () => { + it('returns empty array when no decorations exist for model', () => { + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations).toEqual([]); + }); + + it('returns decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations[0]).toEqual({ decoration: 'decorationValue' }); + }); + }); + + describe('addDecorations', () => { + it('caches decorations in a new map', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('does not create new cache model', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + expect(controller.decorations.keys().next().value).toBe('path'); + }); + + it('calls decorate method', () => { + spyOn(controller, 'decorate'); + + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + spyOn(controller.editor.instance, 'deltaDecorations'); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); + }); + + it('caches decorations', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('path'); + }); + }); + + describe('dispose', () => { + it('clears cached decorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.decorations.size).toBe(0); + }); + + it('clears cached editorDecorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.editorDecorations.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js new file mode 100644 index 00000000000..90216f8b07e --- /dev/null +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -0,0 +1,176 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import ModelManager from 'ee/ide/lib/common/model_manager'; +import DecorationsController from 'ee/ide/lib/decorations/controller'; +import DirtyDiffController, { getDiffChangeType, getDecorator } from 'ee/ide/lib/diff/controller'; +import { computeDiff } from 'ee/ide/lib/diff/diff'; +import { file } from '../../helpers'; + +describe('Multi-file editor library dirty diff controller', () => { + let editorInstance; + let controller; + let modelManager; + let decorationsController; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(monaco); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file('path')); + + controller = new DirtyDiffController(modelManager, decorationsController); + + done(); + }); + }); + + afterEach(() => { + controller.dispose(); + model.dispose(); + decorationsController.dispose(); + editorInstance.dispose(); + }); + + describe('getDiffChangeType', () => { + ['added', 'removed', 'modified'].forEach((type) => { + it(`returns ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDiffChangeType(change)).toBe(type); + }); + }); + }); + + describe('getDecorator', () => { + ['added', 'removed', 'modified'].forEach((type) => { + it(`returns with linesDecorationsClassName for ${type}`, () => { + const change = { + [type]: true, + }; + + expect( + getDecorator(change).options.linesDecorationsClassName, + ).toBe(`dirty-diff dirty-diff-${type}`); + }); + + it('returns with line numbers', () => { + const change = { + lineNumber: 1, + endLineNumber: 2, + [type]: true, + }; + + const range = getDecorator(change).range; + + expect(range.startLineNumber).toBe(1); + expect(range.endLineNumber).toBe(2); + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe(1); + }); + }); + }); + + describe('attachModel', () => { + it('adds change event callback', () => { + spyOn(model, 'onChange'); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + spyOn(controller, 'throttledComputeDiff'); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + spyOn(controller.dirtyDiffWorker, 'postMessage'); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls decorations controller decorate', () => { + spyOn(controller.decorationsController, 'decorate'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + spyOn(controller.decorationsController, 'addDecorations'); + + controller.decorate({ data: { changes: [], path: 'path' } }); + + expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); + }); + + it('adds decorations into editor', () => { + const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations'); + + controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } }); + + expect(spy).toHaveBeenCalledWith([], [{ + range: new monaco.Range( + 1, 1, 1, 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }]); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(controller.disposable, 'dispose').and.callThrough(); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js new file mode 100644 index 00000000000..3bdd0a77e40 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/diff_spec.js @@ -0,0 +1,80 @@ +import { computeDiff } from 'ee/ide/lib/diff/diff'; + +describe('Multi-file editor library diff calculator', () => { + describe('computeDiff', () => { + it('returns empty array if no changes', () => { + const diff = computeDiff('123', '123'); + + expect(diff).toEqual([]); + }); + + describe('modified', () => { + it('', () => { + const diff = computeDiff('123', '1234')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(2); + }); + }); + + describe('added', () => { + it('', () => { + const diff = computeDiff('123', '123\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(3); + }); + }); + + describe('removed', () => { + it('', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeTruthy(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeTruthy(); + expect(diff.lineNumber).toBe(2); + }); + }); + + it('includes line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.lineNumber).toBe(1); + }); + + it('includes end line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.endLineNumber).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js new file mode 100644 index 00000000000..b974a6befd3 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_options_spec.js @@ -0,0 +1,11 @@ +import editorOptions from 'ee/ide/lib/editor_options'; + +describe('Multi-file editor library editor options', () => { + it('returns an array', () => { + expect(editorOptions).toEqual(jasmine.any(Array)); + }); + + it('contains readOnly option', () => { + expect(editorOptions[0].readOnly).toBeDefined(); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js new file mode 100644 index 00000000000..76869bbc7ce --- /dev/null +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -0,0 +1,197 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + let el; + let holder; + + beforeEach(done => { + el = document.createElement('div'); + holder = document.createElement('div'); + el.appendChild(holder); + + document.body.appendChild(el); + + monacoLoader(['vs/editor/editor.main'], () => { + instance = editor.create(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + + el.remove(); + }); + + it('creates instance of editor', () => { + expect(editor.editorInstance).not.toBeNull(); + }); + + it('creates instance returns cached instance', () => { + expect(editor.create(monaco)).toEqual(instance); + }); + + describe('createInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'create').and.callThrough(); + + instance.createInstance(holder); + + expect(instance.monaco.editor.create).toHaveBeenCalled(); + }); + + it('creates dirty diff controller', () => { + instance.createInstance(holder); + + expect(instance.dirtyDiffController).not.toBeNull(); + }); + + it('creates model manager', () => { + instance.createInstance(holder); + + expect(instance.modelManager).not.toBeNull(); + }); + }); + + describe('createDiffInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough(); + + instance.createDiffInstance(holder); + + expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith( + holder, + { + model: null, + contextmenu: true, + minimap: { + enabled: false, + }, + readOnly: true, + scrollBeyondLastLine: false, + }, + ); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + spyOn(instance.modelManager, 'addModel'); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); + }); + }); + + describe('attachModel', () => { + let model; + + beforeEach(() => { + instance.createInstance(document.createElement('div')); + + model = instance.createModel(file()); + }); + + it('sets the current model on the instance', () => { + instance.attachModel(model); + + expect(instance.currentModel).toBe(model); + }); + + it('attaches the model to the current instance', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('sets original & modified when diff editor', () => { + spyOn(instance.instance, 'getEditorType').and.returnValue( + 'vs.editor.IDiffEditor', + ); + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + }); + + it('attaches the model to the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'attachModel'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith( + model, + ); + }); + + it('re-decorates with the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'reDecorate'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith( + model, + ); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + spyOn(instance.instance, 'setModel'); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + + it('resets instance', () => { + instance.createInstance(document.createElement('div')); + + expect(instance.instance).not.toBeNull(); + + instance.dispose(); + + expect(instance.instance).toBeNull(); + }); + + it('does not dispose modelManager', () => { + spyOn(instance.modelManager, 'dispose'); + + instance.dispose(); + + expect(instance.modelManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not dispose decorationsController', () => { + spyOn(instance.decorationsController, 'dispose'); + + instance.dispose(); + + expect(instance.decorationsController.dispose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js new file mode 100644 index 00000000000..43bc256e718 --- /dev/null +++ b/spec/javascripts/ide/monaco_loader_spec.js @@ -0,0 +1,13 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; +import monacoLoader from 'ee/ide/monaco_loader'; + +describe('MonacoLoader', () => { + it('calls require.config and exports require', () => { + expect(monacoContext.require.getConfig()).toEqual(jasmine.objectContaining({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + })); + expect(monacoLoader).toBe(monacoContext.require); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js new file mode 100644 index 00000000000..55563e29d7f --- /dev/null +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -0,0 +1,421 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import eventHub from 'ee/ide/eventhub'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store file actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('closeFile', () => { + let localFile; + + beforeEach(() => { + localFile = file('testFile'); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + store.state.entries[localFile.path] = localFile; + }); + + it('closes open files', done => { + store + .dispatch('closeFile', localFile.path) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes file even if file has changes', done => { + store.state.changedFiles.push(localFile); + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('closes file & opens next available file', done => { + const f = { + ...file('newOpenFile'), + url: '/newOpenFile', + }; + + store.state.openFiles.push(f); + store.state.entries[f.path] = f; + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let localFile; + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jasmine.createSpy('scrollToTab'); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + localFile = file('setThisActive'); + + store.state.entries[localFile.path] = localFile; + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file active', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns early if file is already active', done => { + localFile.active = true; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets current active file to not active', done => { + const f = file('newActive'); + store.state.entries[f.path] = f; + localFile.active = true; + store.state.openFiles.push(localFile); + + store + .dispatch('setFileActive', f.path) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('resets location.hash for line highlighting', done => { + location.hash = 'test'; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(location.hash).not.toBe('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getFileData', () => { + let localFile; + + beforeEach(() => { + spyOn(service, 'getFileData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'testing getFileData', + }, + json: () => + Promise.resolve({ + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }), + }), + ); + + localFile = file(`newCreate-${Math.random()}`); + localFile.url = 'getFileDataURL'; + store.state.entries[localFile.path] = localFile; + }); + + it('calls the service', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file data', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }) + .catch(done.fail); + }); + + it('sets document title', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(document.title).toBe('testing getFileData'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file as active', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }) + .catch(done.fail); + }); + + it('updates file raw data', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content'); + + done(); + }) + .catch(done.fail); + }); + + it('adds file into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file once into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array if not changed', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: '', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardFileChanges', () => { + let tmpFile; + + beforeEach(() => { + spyOn(eventHub, '$on'); + + tmpFile = file(); + tmpFile.content = 'testing'; + + store.state.changedFiles.push(tmpFile); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('resets file content', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.content).not.toBe('testing'); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes temp file', done => { + tmpFile.tempFile = true; + tmpFile.opened = true; + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('does not re-open a closed temp file', done => { + tmpFile.tempFile = true; + + expect(tmpFile.opened).toBeFalsy(); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js new file mode 100644 index 00000000000..dba4bc10f9d --- /dev/null +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -0,0 +1,145 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + let projectTree; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + branchId: 'master', + }; + + beforeEach(() => { + spyOn(router, 'push'); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('getFiles', () => { + beforeEach(() => { + spyOn(service, 'getFiles').and.returnValue(Promise.resolve({ + json: () => Promise.resolve([ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]), + })); + }); + + it('calls service getFiles', (done) => { + store.dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + + done(); + }).catch(done.fail); + }); + + it('adds data into tree', (done) => { + store.dispatch('getFiles', basicCallParameters) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + + done(); + }).catch(done.fail); + }); + }); + + describe('toggleTreeOpen', () => { + let tree; + + beforeEach(() => { + tree = file('testing', '1', 'tree'); + store.state.entries[tree.path] = tree; + }); + + it('toggles the tree open', (done) => { + store.dispatch('toggleTreeOpen', tree.path).then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + }); + + describe('getLastCommitData', () => { + beforeEach(() => { + spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({ + headers: { + 'more-logs-url': null, + }, + json: () => Promise.resolve([{ + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }]), + })); + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + projectTree = store.state.trees['abcproject/mybranch']; + projectTree.tree.push(file('testing', '1', 'tree')); + projectTree.lastCommitPath = 'lastcommitpath'; + }); + + it('calls service with lastCommitPath', (done) => { + store.dispatch('getLastCommitData', projectTree) + .then(() => { + expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); + + done(); + }).catch(done.fail); + }); + + it('updates trees last commit data', (done) => { + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); + + done(); + }).catch(done.fail); + }); + + it('does not update entry if not found', (done) => { + projectTree.tree[0].name = 'a'; + + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); + + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js new file mode 100644 index 00000000000..0da1226c7aa --- /dev/null +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -0,0 +1,306 @@ +import * as urlUtils from '~/lib/utils/url_utility'; +import store from 'ee/ide/stores'; +import router from 'ee/ide/ide_router'; +import { resetStore, file } from '../helpers'; + +describe('Multi-file store actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', done => { + spyOn(urlUtils, 'visitUrl'); + + store + .dispatch('redirectToUrl', 'test') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', done => { + store + .dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + beforeEach(() => { + const f = file('discardAll'); + f.changed = true; + + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); + + it('discards changes in file', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.openFiles.changed).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all files from changedFiles state', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('closeAllFiles', () => { + beforeEach(() => { + const f = file('closeAll'); + store.state.openFiles.push(f); + store.state.openFiles[0].opened = true; + store.state.entries[f.path] = f; + }); + + it('closes all open files', done => { + store + .dispatch('closeAllFiles') + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('createTempEntry', () => { + beforeEach(() => { + document.body.innerHTML += '
'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'mybranch'; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('tree', () => { + it('creates temp tree', done => { + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'test', + type: 'tree', + }) + .then(() => { + const entry = store.state.entries.test; + + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates new folder inside another tree', done => { + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing/test', + type: 'tree', + }) + .then(() => { + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('does not create new tree if already exists', done => { + const tree = { + type: 'tree', + path: 'testing', + tempFile: false, + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing', + type: 'tree', + }) + .then(() => { + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('blob', () => { + it('creates temp file', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.tempFile).toBeTruthy(); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe( + 1, + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to open files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to changed files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.changedFiles.length).toBe(1); + expect(store.state.changedFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('sets tmp file as active', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('creates flash message if file already exists', done => { + const f = file('test', '1', 'blob'); + store.state.trees['abcproject/mybranch'].tree = [f]; + store.state.entries[f.path] = f; + + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('popHistoryState', () => {}); + + describe('scrollToTab', () => { + it('focuses the current active element', done => { + document.body.innerHTML += + '
'; + const el = document.querySelector('.repo-tab'); + spyOn(el, 'focus'); + + store + .dispatch('scrollToTab') + .then(() => { + setTimeout(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); + + describe('updateViewer', () => { + it('updates viewer state', done => { + store + .dispatch('updateViewer', 'diff') + .then(() => { + expect(store.state.viewer).toBe('diff'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js new file mode 100644 index 00000000000..2fb69339915 --- /dev/null +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -0,0 +1,55 @@ +import * as getters from 'ee/ide/stores/getters'; +import state from 'ee/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('activeFile', () => { + it('returns the current active file', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + localState.openFiles[1].active = true; + + expect(getters.activeFile(localState).name).toBe('active'); + }); + + it('returns undefined if no active files are found', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + + expect(getters.activeFile(localState)).toBeNull(); + }); + }); + + describe('modifiedFiles', () => { + it('returns a list of modified files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('changed')); + localState.changedFiles[0].changed = true; + + const modifiedFiles = getters.modifiedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('changed'); + }); + }); + + describe('addedFiles', () => { + it('returns a list of added files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('added')); + localState.changedFiles[0].changed = true; + localState.changedFiles[0].tempFile = true; + + const modifiedFiles = getters.addedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('added'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js new file mode 100644 index 00000000000..0aef29f77e3 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -0,0 +1,450 @@ +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import * as urlUtils from '~/lib/utils/url_utility'; +import eventHub from 'ee/ide/eventhub'; +import * as consts from 'ee/ide/stores/modules/commit/constants'; +import { resetStore, file } from 'spec/ide/helpers'; + +describe('IDE commit module actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('updateCommitMessage', () => { + it('updates store with new commit message', (done) => { + store.dispatch('commit/updateCommitMessage', 'testing') + .then(() => { + expect(store.state.commit.commitMessage).toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardDraft', () => { + it('resets commit message to blank', (done) => { + store.state.commit.commitMessage = 'testing'; + + store.dispatch('commit/discardDraft') + .then(() => { + expect(store.state.commit.commitMessage).not.toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCommitAction', () => { + it('updates store with new commit action', (done) => { + store.dispatch('commit/updateCommitAction', '1') + .then(() => { + expect(store.state.commit.commitAction).toBe('1'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateBranchName', () => { + it('updates store with new branch name', (done) => { + store.dispatch('commit/updateBranchName', 'branch-name') + .then(() => { + expect(store.state.commit.newBranchName).toBe('branch-name'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setLastCommitMessage', () => { + beforeEach(() => { + Object.assign(store.state, { + currentProjectId: 'abcproject', + projects: { + abcproject: { + web_url: 'http://testing', + }, + }, + }); + }); + + it('updates commit message with short_id', (done) => { + store.dispatch('commit/setLastCommitMessage', { short_id: '123' }) + .then(() => { + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit 123', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('updates commit message with stats', (done) => { + store.dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) + .then(() => { + expect(store.state.lastCommitMsg).toBe('Your changes have been committed. Commit 123 with 1 additions, 2 deletions.'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('checkCommitStatus', () => { + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + it('calls service', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '123' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('returns true if current ref does not equal returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '123' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then((val) => { + expect(val).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns false if current ref equals returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '1' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then((val) => { + expect(val).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('updateFilesAfterCommit', () => { + const data = { + id: '123', + message: 'testing commit message', + committed_date: '123', + committer_name: 'root', + }; + const branch = 'master'; + let f; + + beforeEach(() => { + spyOn(eventHub, '$emit'); + + f = file('changedFile'); + Object.assign(f, { + active: true, + changed: true, + content: 'file content', + }); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + }, + }, + }; + store.state.changedFiles.push(f, { + ...file('changedFile2'), + changed: true, + }); + store.state.openFiles = store.state.changedFiles; + + store.state.changedFiles.forEach((changedFile) => { + store.state.entries[changedFile.path] = changedFile; + }); + }); + + it('updates stores working reference', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect( + store.state.projects.abcproject.branches.master.workingReference, + ).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('resets all files changed status', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + store.state.openFiles.forEach((entry) => { + expect(entry.changed).toBeFalsy(); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all changed files', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('sets files commit data', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.lastCommit.message).toBe(data.message); + }) + .then(done) + .catch(done.fail); + }); + + it('updates raw content for changed file', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.raw).toBe(f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('emits changed event for file', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.path}`, f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('pushes route to new branch if commitAction is new branch', (done) => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/abcproject/blob/master/${f.path}`, + ); + }) + .then(done) + .catch(done.fail); + }); + + it('resets stores commit actions', (done) => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + beforeEach(() => { + spyOn(urlUtils, 'visitUrl'); + + document.body.innerHTML += '
'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + }, + }, + }; + store.state.changedFiles.push(file('changed')); + store.state.changedFiles[0].active = true; + store.state.openFiles = store.state.changedFiles; + + store.state.openFiles.forEach((f) => { + store.state.entries[f.path] = f; + }); + + store.state.commit.commitAction = '2'; + store.state.commit.commitMessage = 'testing 123'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + data: { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, + }, + })); + }); + + it('calls service', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: jasmine.anything(), + commit_message: 'testing 123', + actions: [{ + action: 'update', + file_path: jasmine.anything(), + content: jasmine.anything(), + encoding: jasmine.anything(), + }], + start_branch: 'master', + }); + + done(); + }).catch(done.fail); + }); + + it('pushes router to new route', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/${store.state.currentProjectId}/blob/${store.getters['commit/newBranchName']}/changed`, + ); + + done(); + }).catch(done.fail); + }); + + it('sets last Commit Msg', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.', + ); + + done(); + }).catch(done.fail); + }); + + it('adds commit data to changed files', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(store.state.openFiles[0].lastCommit.message).toBe('test message'); + + done(); + }).catch(done.fail); + }); + + it('redirects to new merge request page', (done) => { + spyOn(eventHub, '$on'); + + store.state.commit.commitAction = '3'; + + store.dispatch('commit/commitChanges') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + `webUrl/merge_requests/new?merge_request[source_branch]=${store.getters['commit/newBranchName']}&merge_request[target_branch]=master`, + ); + + done(); + }).catch(done.fail); + }); + }); + + describe('failed', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + data: { + message: 'failed message', + }, + })); + }); + + it('shows failed message', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe( + 'failed message', + ); + + done(); + }).catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js new file mode 100644 index 00000000000..b1467bcf3c7 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -0,0 +1,114 @@ +import commitState from 'ee/ide/stores/modules/commit/state'; +import * as consts from 'ee/ide/stores/modules/commit/constants'; +import * as getters from 'ee/ide/stores/modules/commit/getters'; + +describe('IDE commit module getters', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('discardDraftButtonDisabled', () => { + it('returns true when commitMessage is empty', () => { + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + + it('returns false when commitMessage is not empty & loading is false', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = false; + + expect(getters.discardDraftButtonDisabled(state)).toBeFalsy(); + }); + + it('returns true when commitMessage is not empty & loading is true', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = true; + + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + }); + + describe('commitButtonDisabled', () => { + const localGetters = { + discardDraftButtonDisabled: false, + }; + const rootState = { + changedFiles: ['a'], + }; + + it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeFalsy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { + rootState.changedFiles.length = 0; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is true', () => { + localGetters.discardDraftButtonDisabled = true; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + localGetters.discardDraftButtonDisabled = false; + rootState.changedFiles.length = 0; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + }); + + describe('newBranchName', () => { + it('includes username, currentBranchId, patch & random number', () => { + gon.current_username = 'username'; + + const branch = getters.newBranchName(state, null, { currentBranchId: 'testing' }); + + expect(branch).toMatch(/username-testing-patch-\d{5}$/); + }); + }); + + describe('branchName', () => { + const rootState = { + currentBranchId: 'master', + }; + const localGetters = { + newBranchName: 'newBranchName', + }; + + beforeEach(() => { + Object.assign(state, { + newBranchName: 'state-newBranchName', + }); + }); + + it('defualts to currentBranchId', () => { + expect(getters.branchName(state, null, rootState)).toBe('master'); + }); + + ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + Object.assign(state, { + commitAction: consts[type], + }); + }); + + it('uses newBranchName when not empty', () => { + expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName'); + }); + + it('uses getters newBranchName when state newBranchName is empty', () => { + Object.assign(state, { + newBranchName: '', + }); + + expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName'); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js new file mode 100644 index 00000000000..fa43e3d9d02 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js @@ -0,0 +1,42 @@ +import commitState from 'ee/ide/stores/modules/commit/state'; +import mutations from 'ee/ide/stores/modules/commit/mutations'; + +describe('IDE commit module mutations', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('UPDATE_COMMIT_MESSAGE', () => { + it('updates commitMessage', () => { + mutations.UPDATE_COMMIT_MESSAGE(state, 'testing'); + + expect(state.commitMessage).toBe('testing'); + }); + }); + + describe('UPDATE_COMMIT_ACTION', () => { + it('updates commitAction', () => { + mutations.UPDATE_COMMIT_ACTION(state, 'testing'); + + expect(state.commitAction).toBe('testing'); + }); + }); + + describe('UPDATE_NEW_BRANCH_NAME', () => { + it('updates newBranchName', () => { + mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing'); + + expect(state.newBranchName).toBe('testing'); + }); + }); + + describe('UPDATE_LOADING', () => { + it('updates submitCommitLoading', () => { + mutations.UPDATE_LOADING(state, true); + + expect(state.submitCommitLoading).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js new file mode 100644 index 00000000000..1601769144a --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -0,0 +1,18 @@ +import mutations from 'ee/ide/stores/mutations/branch'; +import state from 'ee/ide/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('SET_CURRENT_BRANCH', () => { + it('sets currentBranch', () => { + mutations.SET_CURRENT_BRANCH(localState, 'master'); + + expect(localState.currentBranchId).toBe('master'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js new file mode 100644 index 00000000000..944639c3336 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -0,0 +1,157 @@ +import mutations from 'ee/ide/stores/mutations/file'; +import state from 'ee/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store file mutations', () => { + let localState; + let localFile; + + beforeEach(() => { + localState = state(); + localFile = file(); + + localState.entries[localFile.path] = localFile; + }); + + describe('SET_FILE_ACTIVE', () => { + it('sets the file active', () => { + mutations.SET_FILE_ACTIVE(localState, { + path: localFile.path, + active: true, + }); + + expect(localFile.active).toBeTruthy(); + }); + }); + + describe('TOGGLE_FILE_OPEN', () => { + beforeEach(() => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + }); + + it('adds into opened files', () => { + expect(localFile.opened).toBeTruthy(); + expect(localState.openFiles.length).toBe(1); + }); + + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + describe('SET_FILE_DATA', () => { + it('sets extra file data', () => { + mutations.SET_FILE_DATA(localState, { + data: { + blame_path: 'blame', + commits_path: 'commits', + permalink: 'permalink', + raw_path: 'raw', + binary: true, + render_error: 'render_error', + }, + file: localFile, + }); + + expect(localFile.blamePath).toBe('blame'); + expect(localFile.commitsPath).toBe('commits'); + expect(localFile.permalink).toBe('permalink'); + expect(localFile.rawPath).toBe('raw'); + expect(localFile.binary).toBeTruthy(); + expect(localFile.renderError).toBe('render_error'); + }); + }); + + describe('SET_FILE_RAW_DATA', () => { + it('sets raw data', () => { + mutations.SET_FILE_RAW_DATA(localState, { + file: localFile, + raw: 'testing', + }); + + expect(localFile.raw).toBe('testing'); + }); + }); + + describe('UPDATE_FILE_CONTENT', () => { + beforeEach(() => { + localFile.raw = 'test'; + }); + + it('sets content', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'test', + }); + + expect(localFile.content).toBe('test'); + }); + + it('sets changed if content does not match raw', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'testing', + }); + + expect(localFile.content).toBe('testing'); + expect(localFile.changed).toBeTruthy(); + }); + + it('sets changed if file is a temp file', () => { + localFile.tempFile = true; + + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: '', + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); + + describe('DISCARD_FILE_CHANGES', () => { + beforeEach(() => { + localFile.content = 'test'; + localFile.changed = true; + }); + + it('resets content and changed', () => { + mutations.DISCARD_FILE_CHANGES(localState, localFile.path); + + expect(localFile.content).toBe(''); + expect(localFile.changed).toBeFalsy(); + }); + }); + + describe('ADD_FILE_TO_CHANGED', () => { + it('adds file into changed files array', () => { + mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(1); + }); + }); + + describe('REMOVE_FILE_FROM_CHANGED', () => { + it('removes files from changed files array', () => { + localState.changedFiles.push(localFile); + + mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(0); + }); + }); + + describe('TOGGLE_FILE_CHANGED', () => { + it('updates file changed status', () => { + mutations.TOGGLE_FILE_CHANGED(localState, { + file: localFile, + changed: true, + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js new file mode 100644 index 00000000000..e321eff8749 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/tree_spec.js @@ -0,0 +1,67 @@ +import mutations from 'ee/ide/stores/mutations/tree'; +import state from 'ee/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store tree mutations', () => { + let localState; + let localTree; + + beforeEach(() => { + localState = state(); + localTree = file(); + + localState.entries[localTree.path] = localTree; + }); + + describe('TOGGLE_TREE_OPEN', () => { + it('toggles tree open', () => { + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeTruthy(); + + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeFalsy(); + }); + }); + + describe('SET_DIRECTORY_DATA', () => { + const data = [{ + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }]; + + it('adds directory data', () => { + localState.trees['project/master'] = { + tree: [], + }; + + mutations.SET_DIRECTORY_DATA(localState, { + data, + treePath: 'project/master', + }); + + const tree = localState.trees['project/master']; + + expect(tree.tree.length).toBe(3); + expect(tree.tree[0].name).toBe('tree'); + expect(tree.tree[1].name).toBe('submodule'); + expect(tree.tree[2].name).toBe('blob'); + }); + }); + + describe('REMOVE_ALL_CHANGES_FILES', () => { + it('removes all files from changedFiles state', () => { + localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES')); + + mutations.REMOVE_ALL_CHANGES_FILES(localState); + + expect(localState.changedFiles.length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js new file mode 100644 index 00000000000..e0d214010d5 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -0,0 +1,79 @@ +import mutations from 'ee/ide/stores/mutations'; +import state from 'ee/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store mutations', () => { + let localState; + let entry; + + beforeEach(() => { + localState = state(); + entry = file(); + + localState.entries[entry.path] = entry; + }); + + describe('SET_INITIAL_DATA', () => { + it('sets all initial data', () => { + mutations.SET_INITIAL_DATA(localState, { + test: 'test', + }); + + expect(localState.test).toBe('test'); + }); + }); + + describe('TOGGLE_LOADING', () => { + it('toggles loading of entry', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeFalsy(); + }); + + it('toggles loading of entry and sets specific value', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry, forceValue: true }); + + expect(entry.loading).toBeTruthy(); + }); + }); + + describe('SET_LEFT_PANEL_COLLAPSED', () => { + it('sets left panel collapsed', () => { + mutations.SET_LEFT_PANEL_COLLAPSED(localState, true); + + expect(localState.leftPanelCollapsed).toBeTruthy(); + + mutations.SET_LEFT_PANEL_COLLAPSED(localState, false); + + expect(localState.leftPanelCollapsed).toBeFalsy(); + }); + }); + + describe('SET_RIGHT_PANEL_COLLAPSED', () => { + it('sets right panel collapsed', () => { + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true); + + expect(localState.rightPanelCollapsed).toBeTruthy(); + + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false); + + expect(localState.rightPanelCollapsed).toBeFalsy(); + }); + }); + + describe('UPDATE_VIEWER', () => { + it('sets viewer state', () => { + mutations.UPDATE_VIEWER(localState, 'diff'); + + expect(localState.viewer).toBe('diff'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js new file mode 100644 index 00000000000..a473d3a4294 --- /dev/null +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -0,0 +1,60 @@ +import * as utils from 'ee/ide/stores/utils'; + +describe('Multi-file store utils', () => { + describe('setPageTitle', () => { + it('sets the document page title', () => { + utils.setPageTitle('test'); + + expect(document.title).toBe('test'); + }); + }); + + describe('findIndexOfFile', () => { + let localState; + + beforeEach(() => { + localState = [{ + path: '1', + }, { + path: '2', + }]; + }); + + it('finds in the index of an entry by path', () => { + const index = utils.findIndexOfFile(localState, { + path: '2', + }); + + expect(index).toBe(1); + }); + }); + + describe('findEntry', () => { + let localState; + + beforeEach(() => { + localState = { + tree: [{ + type: 'tree', + name: 'test', + }, { + type: 'blob', + name: 'file', + }], + }; + }); + + it('returns an entry found by name', () => { + const foundEntry = utils.findEntry(localState.tree, 'tree', 'test'); + + expect(foundEntry.type).toBe('tree'); + expect(foundEntry.name).toBe('test'); + }); + + it('returns undefined when no entry found', () => { + const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); + + expect(foundEntry).toBeUndefined(); + }); + }); +}); -- cgit v1.2.1 From af22ddafe4e580850e183993ae276b1f0f565ca6 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 20 Mar 2018 14:16:38 +0000 Subject: updated file references in specs --- .../ide/stores/modules/commit/actions.js | 6 +- .../ide/components/changed_file_icon_spec.js | 2 +- .../ide/components/commit_sidebar/actions_spec.js | 6 +- .../commit_sidebar/list_collapsed_spec.js | 4 +- .../components/commit_sidebar/list_item_spec.js | 8 +- .../ide/components/commit_sidebar/list_spec.js | 8 +- .../components/commit_sidebar/radio_group_spec.js | 18 +- .../ide/components/ide_context_bar_spec.js | 6 +- .../ide/components/ide_external_links_spec.js | 2 +- .../ide/components/ide_repo_tree_spec.js | 8 +- .../ide/components/ide_side_bar_spec.js | 18 +- spec/javascripts/ide/components/ide_spec.js | 6 +- .../ide/components/new_dropdown/index_spec.js | 4 +- .../ide/components/new_dropdown/modal_spec.js | 30 +- .../ide/components/new_dropdown/upload_spec.js | 2 +- .../ide/components/repo_commit_section_spec.js | 79 +++-- .../javascripts/ide/components/repo_editor_spec.js | 20 +- .../ide/components/repo_file_buttons_spec.js | 8 +- spec/javascripts/ide/components/repo_file_spec.js | 6 +- .../ide/components/repo_loading_file_spec.js | 8 +- spec/javascripts/ide/components/repo_tab_spec.js | 16 +- spec/javascripts/ide/components/repo_tabs_spec.js | 2 +- spec/javascripts/ide/helpers.js | 27 +- spec/javascripts/ide/lib/common/disposable_spec.js | 2 +- .../ide/lib/common/model_manager_spec.js | 18 +- spec/javascripts/ide/lib/common/model_spec.js | 20 +- .../ide/lib/decorations/controller_spec.js | 47 ++- spec/javascripts/ide/lib/diff/controller_spec.js | 78 +++-- spec/javascripts/ide/lib/diff/diff_spec.js | 2 +- spec/javascripts/ide/lib/editor_options_spec.js | 2 +- spec/javascripts/ide/lib/editor_spec.js | 4 +- spec/javascripts/ide/monaco_loader_spec.js | 14 +- spec/javascripts/ide/stores/actions/file_spec.js | 8 +- spec/javascripts/ide/stores/actions/tree_spec.js | 125 +++++--- spec/javascripts/ide/stores/actions_spec.js | 4 +- spec/javascripts/ide/stores/getters_spec.js | 4 +- .../ide/stores/modules/commit/actions_spec.js | 333 ++++++++++++--------- .../ide/stores/modules/commit/getters_spec.js | 36 ++- .../ide/stores/modules/commit/mutations_spec.js | 4 +- .../ide/stores/mutations/branch_spec.js | 4 +- spec/javascripts/ide/stores/mutations/file_spec.js | 4 +- spec/javascripts/ide/stores/mutations/tree_spec.js | 24 +- spec/javascripts/ide/stores/mutations_spec.js | 4 +- spec/javascripts/ide/stores/utils_spec.js | 32 +- 44 files changed, 632 insertions(+), 431 deletions(-) diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 2e1aea9a399..f536ce6344b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -30,9 +30,9 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => { const currentProject = rootState.projects[rootState.currentProjectId]; const commitStats = data.stats ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), { - additions: data.stats.additions, - deletions: data.stats.deletions, - }) + additions: data.stats.additions, // eslint-disable-line indent + deletions: data.stats.deletions, // eslint-disable-line indent + }) // eslint-disable-line indent : ''; const commitMsg = sprintf( __('Your changes have been committed. Commit %{commitId} %{commitStats}'), diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js index 8f796b2f7f5..987aea7befc 100644 --- a/spec/javascripts/ide/components/changed_file_icon_spec.js +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import changedFileIcon from 'ee/ide/components/changed_file_icon.vue'; +import changedFileIcon from '~/ide/components/changed_file_icon.vue'; import createComponent from 'spec/helpers/vue_mount_component_helper'; describe('IDE changed file icon', () => { diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js index 47a9007a8d0..144e78d14b5 100644 --- a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js @@ -1,13 +1,13 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import commitActions from 'ee/ide/components/commit_sidebar/actions.vue'; +import store from '~/ide/stores'; +import commitActions from '~/ide/components/commit_sidebar/actions.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from 'spec/ide/helpers'; describe('IDE commit sidebar actions', () => { let vm; - beforeEach((done) => { + beforeEach(done => { const Component = Vue.extend(commitActions); vm = createComponentWithStore(Component, store); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js index f6789bc861f..5b402886b55 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import listCollapsed from 'ee/ide/components/commit_sidebar/list_collapsed.vue'; +import store from '~/ide/stores'; +import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file } from '../../helpers'; diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js index 543299950ea..15b66952d99 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import listItem from 'ee/ide/components/commit_sidebar/list_item.vue'; -import router from 'ee/ide/ide_router'; +import listItem from '~/ide/components/commit_sidebar/list_item.vue'; +import router from '~/ide/ide_router'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { file } from '../../helpers'; @@ -23,7 +23,9 @@ describe('Multi-file editor commit sidebar list item', () => { }); it('renders file path', () => { - expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); + expect( + vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim(), + ).toBe(f.path); }); it('calls discardFileChanges when clicking discard button', () => { diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js index f02d055e38c..a62c0a28340 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import commitSidebarList from 'ee/ide/components/commit_sidebar/list.vue'; +import store from '~/ide/stores'; +import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file } from '../../helpers'; @@ -25,7 +25,7 @@ describe('Multi-file editor commit sidebar list', () => { }); describe('with a list of files', () => { - beforeEach((done) => { + beforeEach(done => { const f = file('file name'); f.changed = true; vm.fileList.push(f); @@ -39,7 +39,7 @@ describe('Multi-file editor commit sidebar list', () => { }); describe('collapsed', () => { - beforeEach((done) => { + beforeEach(done => { vm.$store.state.rightPanelCollapsed = true; Vue.nextTick(done); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js index 1058cc28de2..4e8243439f3 100644 --- a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -1,13 +1,13 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import radioGroup from 'ee/ide/components/commit_sidebar/radio_group.vue'; +import store from '~/ide/stores'; +import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from 'spec/ide/helpers'; describe('IDE commit sidebar radio group', () => { let vm; - beforeEach((done) => { + beforeEach(done => { const Component = Vue.extend(radioGroup); store.state.commit.commitAction = '2'; @@ -33,7 +33,7 @@ describe('IDE commit sidebar radio group', () => { expect(vm.$el.textContent).toContain('test'); }); - it('uses slot if label is not present', (done) => { + it('uses slot if label is not present', done => { vm.$destroy(); vm = new Vue({ @@ -59,7 +59,7 @@ describe('IDE commit sidebar radio group', () => { }); }); - it('updates store when changing radio button', (done) => { + it('updates store when changing radio button', done => { vm.$el.querySelector('input').dispatchEvent(new Event('change')); Vue.nextTick(() => { @@ -69,7 +69,7 @@ describe('IDE commit sidebar radio group', () => { }); }); - it('renders helpText tooltip', (done) => { + it('renders helpText tooltip', done => { vm.helpText = 'help text'; Vue.nextTick(() => { @@ -83,7 +83,7 @@ describe('IDE commit sidebar radio group', () => { }); describe('with input', () => { - beforeEach((done) => { + beforeEach(done => { vm.$destroy(); const Component = Vue.extend(radioGroup); @@ -106,7 +106,7 @@ describe('IDE commit sidebar radio group', () => { expect(vm.$el.querySelector('.form-control')).not.toBeNull(); }); - it('hides input when commitAction doesnt match value', (done) => { + it('hides input when commitAction doesnt match value', done => { store.state.commit.commitAction = '2'; Vue.nextTick(() => { @@ -115,7 +115,7 @@ describe('IDE commit sidebar radio group', () => { }); }); - it('updates branch name in store on input', (done) => { + it('updates branch name in store on input', done => { const input = vm.$el.querySelector('.form-control'); input.value = 'testing-123'; input.dispatchEvent(new Event('input')); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js index 9fa2e947db2..e17b051f137 100644 --- a/spec/javascripts/ide/components/ide_context_bar_spec.js +++ b/spec/javascripts/ide/components/ide_context_bar_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import ideContextBar from 'ee/ide/components/ide_context_bar.vue'; +import store from '~/ide/stores'; +import ideContextBar from '~/ide/components/ide_context_bar.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; describe('Multi-file editor right context bar', () => { @@ -24,7 +24,7 @@ describe('Multi-file editor right context bar', () => { }); describe('collapsed', () => { - beforeEach((done) => { + beforeEach(done => { vm.$store.state.rightPanelCollapsed = true; Vue.nextTick(done); diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js index b8da6747653..9f6cb459f3b 100644 --- a/spec/javascripts/ide/components/ide_external_links_spec.js +++ b/spec/javascripts/ide/components/ide_external_links_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ideExternalLinks from 'ee/ide/components/ide_external_links.vue'; +import ideExternalLinks from '~/ide/components/ide_external_links.vue'; import createComponent from 'spec/helpers/vue_mount_component_helper'; describe('ide external links component', () => { diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js index e7188490f64..e0fbc90ca61 100644 --- a/spec/javascripts/ide/components/ide_repo_tree_spec.js +++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ideRepoTree from 'ee/ide/components/ide_repo_tree.vue'; +import ideRepoTree from '~/ide/components/ide_repo_tree.vue'; import createComponent from '../../helpers/vue_mount_component_helper'; import { file } from '../helpers'; @@ -29,11 +29,13 @@ describe('IdeRepoTree', () => { expect(vm.$el.querySelector('.file')).not.toBeNull(); }); - it('renders 3 loading files if tree is loading', (done) => { + it('renders 3 loading files if tree is loading', done => { tree.loading = true; vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toEqual(3); + expect( + vm.$el.querySelectorAll('.multi-file-loading-container').length, + ).toEqual(3); done(); }); diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js index 74afca280d1..699dae1ce2f 100644 --- a/spec/javascripts/ide/components/ide_side_bar_spec.js +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import ideSidebar from 'ee/ide/components/ide_side_bar.vue'; +import store from '~/ide/stores'; +import ideSidebar from '~/ide/components/ide_side_bar.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../helpers'; @@ -20,15 +20,21 @@ describe('IdeSidebar', () => { }); it('renders a sidebar', () => { - expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); + expect( + vm.$el.querySelector('.multi-file-commit-panel-inner'), + ).not.toBeNull(); }); - it('renders loading icon component', (done) => { + it('renders loading icon component', done => { vm.$store.state.loading = true; vm.$nextTick(() => { - expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); - expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + expect( + vm.$el.querySelector('.multi-file-loading-container'), + ).not.toBeNull(); + expect( + vm.$el.querySelectorAll('.multi-file-loading-container').length, + ).toBe(3); done(); }); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js index 7f8dcd9049f..5bd890094cc 100644 --- a/spec/javascripts/ide/components/ide_spec.js +++ b/spec/javascripts/ide/components/ide_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import ide from 'ee/ide/components/ide.vue'; +import store from '~/ide/stores'; +import ide from '~/ide/components/ide.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file, resetStore } from '../helpers'; @@ -27,7 +27,7 @@ describe('ide component', () => { expect(vm.$el.querySelector('.panel-right')).toBeNull(); }); - it('renders panel right when files are open', (done) => { + it('renders panel right when files are open', done => { vm.$store.state.trees['abcproject/mybranch'] = { tree: [file()], }; diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js index cba27f94833..e08abe7d849 100644 --- a/spec/javascripts/ide/components/new_dropdown/index_spec.js +++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import newDropdown from 'ee/ide/components/new_dropdown/index.vue'; +import store from '~/ide/stores'; +import newDropdown from '~/ide/components/new_dropdown/index.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../../helpers'; diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js index 1a9c96c64da..a6e1e5a0d35 100644 --- a/spec/javascripts/ide/components/new_dropdown/modal_spec.js +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import modal from 'ee/ide/components/new_dropdown/modal.vue'; +import modal from '~/ide/components/new_dropdown/modal.vue'; import createComponent from 'spec/helpers/vue_mount_component_helper'; describe('new file modal component', () => { @@ -10,7 +10,7 @@ describe('new file modal component', () => { vm.$destroy(); }); - ['tree', 'blob'].forEach((type) => { + ['tree', 'blob'].forEach(type => { describe(type, () => { beforeEach(() => { vm = createComponent(Component, { @@ -25,19 +25,25 @@ describe('new file modal component', () => { it(`sets modal title as ${type}`, () => { const title = type === 'tree' ? 'directory' : 'file'; - expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`); + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe( + `Create new ${title}`, + ); }); it(`sets button label as ${type}`, () => { const title = type === 'tree' ? 'directory' : 'file'; - expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`); + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe( + `Create ${title}`, + ); }); it(`sets form label as ${type}`, () => { const title = type === 'tree' ? 'Directory' : 'File'; - expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe( + `${title} name`, + ); }); describe('createEntryInStore', () => { @@ -59,11 +65,15 @@ describe('new file modal component', () => { it('focuses field on mount', () => { document.body.innerHTML += '
'; - vm = createComponent(Component, { - type: 'tree', - branchId: 'master', - path: '', - }, '.js-test'); + vm = createComponent( + Component, + { + type: 'tree', + branchId: 'master', + path: '', + }, + '.js-test', + ); expect(document.activeElement).toBe(vm.$refs.fieldName); diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js index 766e8b72360..2bc5d701601 100644 --- a/spec/javascripts/ide/components/new_dropdown/upload_spec.js +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import upload from 'ee/ide/components/new_dropdown/upload.vue'; +import upload from '~/ide/components/new_dropdown/upload.vue'; import createComponent from 'spec/helpers/vue_mount_component_helper'; describe('new dropdown upload', () => { diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js index 8090e3664e0..113ade269e9 100644 --- a/spec/javascripts/ide/components/repo_commit_section_spec.js +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import service from 'ee/ide/services'; -import repoCommitSection from 'ee/ide/components/repo_commit_section.vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import repoCommitSection from '~/ide/components/repo_commit_section.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; import { file, resetStore } from '../helpers'; @@ -31,30 +31,35 @@ describe('RepoCommitSection', () => { vm.$store.state.rightPanelCollapsed = false; vm.$store.state.currentBranch = 'master'; vm.$store.state.changedFiles = [file('file1'), file('file2')]; - vm.$store.state.changedFiles.forEach(f => Object.assign(f, { - changed: true, - content: 'testing', - })); + vm.$store.state.changedFiles.forEach(f => + Object.assign(f, { + changed: true, + content: 'testing', + }), + ); return vm.$mount(); } - beforeEach((done) => { + beforeEach(done => { vm = createComponent(); - spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({ - headers: { - 'page-title': 'test', - }, - json: () => Promise.resolve({ - last_commit_path: 'last_commit_path', - parent_tree_url: 'parent_tree_url', - path: '/', - trees: [{ name: 'tree' }], - blobs: [{ name: 'blob' }], - submodules: [{ name: 'submodule' }], + spyOn(service, 'getTreeData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => + Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), }), - })); + ); Vue.nextTick(done); }); @@ -75,27 +80,35 @@ describe('RepoCommitSection', () => { committedStateSvgPath: 'svg', }).$mount(); - expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); - expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg'); + expect( + vm.$el.querySelector('.js-empty-state').textContent.trim(), + ).toContain('No changes'); + expect( + vm.$el.querySelector('.js-empty-state img').getAttribute('src'), + ).toBe('nochangessvg'); }); }); it('renders a commit section', () => { - const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')]; + const changedFileElements = [ + ...vm.$el.querySelectorAll('.multi-file-commit-list li'), + ]; const submitCommit = vm.$el.querySelector('form .btn'); expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); expect(changedFileElements.length).toEqual(2); changedFileElements.forEach((changedFile, i) => { - expect(changedFile.textContent.trim()).toContain(vm.$store.state.changedFiles[i].path); + expect(changedFile.textContent.trim()).toContain( + vm.$store.state.changedFiles[i].path, + ); }); expect(submitCommit.disabled).toBeTruthy(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); }); - it('updates commitMessage in store on input', (done) => { + it('updates commitMessage in store on input', done => { const textarea = vm.$el.querySelector('textarea'); textarea.value = 'testing commit message'; @@ -104,7 +117,9 @@ describe('RepoCommitSection', () => { getSetTimeoutPromise() .then(() => { - expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); + expect(vm.$store.state.commit.commitMessage).toBe( + 'testing commit message', + ); }) .then(done) .catch(done.fail); @@ -112,10 +127,12 @@ describe('RepoCommitSection', () => { describe('discard draft button', () => { it('hidden when commitMessage is empty', () => { - expect(vm.$el.querySelector('.multi-file-commit-form .btn-default')).toBeNull(); + expect( + vm.$el.querySelector('.multi-file-commit-form .btn-default'), + ).toBeNull(); }); - it('resets commitMessage when clicking discard button', (done) => { + it('resets commitMessage when clicking discard button', done => { vm.$store.state.commit.commitMessage = 'testing commit message'; getSetTimeoutPromise() @@ -124,7 +141,9 @@ describe('RepoCommitSection', () => { }) .then(Vue.nextTick) .then(() => { - expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message'); + expect(vm.$store.state.commit.commitMessage).not.toBe( + 'testing commit message', + ); }) .then(done) .catch(done.fail); @@ -136,7 +155,7 @@ describe('RepoCommitSection', () => { spyOn(vm, 'commitChanges'); }); - it('calls commitChanges', (done) => { + it('calls commitChanges', done => { vm.$store.state.commit.commitMessage = 'testing commit message'; getSetTimeoutPromise() diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index cda88623497..ae657e8c881 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -1,15 +1,15 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import repoEditor from 'ee/ide/components/repo_editor.vue'; -import monacoLoader from 'ee/ide/monaco_loader'; -import Editor from 'ee/ide/lib/editor'; +import store from '~/ide/stores'; +import repoEditor from '~/ide/components/repo_editor.vue'; +import monacoLoader from '~/ide/monaco_loader'; +import Editor from '~/ide/lib/editor'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { file, resetStore } from '../helpers'; describe('RepoEditor', () => { let vm; - beforeEach((done) => { + beforeEach(done => { const f = file(); const RepoEditor = Vue.extend(repoEditor); @@ -39,7 +39,7 @@ describe('RepoEditor', () => { Editor.editorInstance.modelManager.dispose(); }); - it('renders an ide container', (done) => { + it('renders an ide container', done => { Vue.nextTick(() => { expect(vm.shouldHideEditor).toBeFalsy(); @@ -48,7 +48,7 @@ describe('RepoEditor', () => { }); describe('when open file is binary and not raw', () => { - beforeEach((done) => { + beforeEach(done => { vm.file.binary = true; vm.$nextTick(done); @@ -64,7 +64,7 @@ describe('RepoEditor', () => { }); describe('createEditorInstance', () => { - it('calls createInstance when viewer is editor', (done) => { + it('calls createInstance when viewer is editor', done => { spyOn(vm.editor, 'createInstance'); vm.createEditorInstance(); @@ -76,7 +76,7 @@ describe('RepoEditor', () => { }); }); - it('calls createDiffInstance when viewer is diff', (done) => { + it('calls createDiffInstance when viewer is diff', done => { vm.$store.state.viewer = 'diff'; spyOn(vm.editor, 'createDiffInstance'); @@ -124,7 +124,7 @@ describe('RepoEditor', () => { expect(vm.model.events.size).toBe(1); }); - it('updates state when model content changed', (done) => { + it('updates state when model content changed', done => { vm.model.setValue('testing 123'); setTimeout(() => { diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js index 13b452b1936..c86bdb132b4 100644 --- a/spec/javascripts/ide/components/repo_file_buttons_spec.js +++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import repoFileButtons from 'ee/ide/components/repo_file_buttons.vue'; +import repoFileButtons from '~/ide/components/repo_file_buttons.vue'; import createVueComponent from '../../helpers/vue_mount_component_helper'; import { file } from '../helpers'; @@ -23,7 +23,7 @@ describe('RepoFileButtons', () => { vm.$destroy(); }); - it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => { + it('renders Raw, Blame, History, Permalink and Preview toggle', done => { vm = createComponent(); vm.$nextTick(() => { @@ -37,7 +37,9 @@ describe('RepoFileButtons', () => { expect(blame.textContent.trim()).toEqual('Blame'); expect(history.href).toMatch(`/${activeFile.commitsPath}`); expect(history.textContent.trim()).toEqual('History'); - expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual( + 'Permalink', + ); done(); }); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js index 3bd871544ea..ff391cb4351 100644 --- a/spec/javascripts/ide/components/repo_file_spec.js +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import repoFile from 'ee/ide/components/repo_file.vue'; -import router from 'ee/ide/ide_router'; +import store from '~/ide/stores'; +import repoFile from '~/ide/components/repo_file.vue'; +import router from '~/ide/ide_router'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { file } from '../helpers'; diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js index dd267654289..8f9644216bc 100644 --- a/spec/javascripts/ide/components/repo_loading_file_spec.js +++ b/spec/javascripts/ide/components/repo_loading_file_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import repoLoadingFile from 'ee/ide/components/repo_loading_file.vue'; +import store from '~/ide/stores'; +import repoLoadingFile from '~/ide/components/repo_loading_file.vue'; import { resetStore } from '../helpers'; describe('RepoLoadingFile', () => { @@ -22,7 +22,7 @@ describe('RepoLoadingFile', () => { } function assertColumns(columns) { - columns.forEach((column) => { + columns.forEach(column => { const container = column.querySelector('.animation-container'); const lines = [...container.querySelectorAll(':scope > div')]; @@ -46,7 +46,7 @@ describe('RepoLoadingFile', () => { assertColumns(columns); }); - it('renders 1 column of animated LoC if isMini', (done) => { + it('renders 1 column of animated LoC if isMini', done => { vm = createComponent(); vm.$store.state.leftPanelCollapsed = true; vm.$store.state.openFiles.push('test'); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js index c3246cd1f1f..ddb5204e3a7 100644 --- a/spec/javascripts/ide/components/repo_tab_spec.js +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import repoTab from 'ee/ide/components/repo_tab.vue'; -import router from 'ee/ide/ide_router'; +import store from '~/ide/stores'; +import repoTab from '~/ide/components/repo_tab.vue'; +import router from '~/ide/ide_router'; import { file, resetStore } from '../helpers'; describe('RepoTab', () => { @@ -62,7 +62,7 @@ describe('RepoTab', () => { expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); }); - it('changes icon on hover', (done) => { + it('changes icon on hover', done => { const tab = file(); tab.changed = true; vm = createComponent({ @@ -112,13 +112,15 @@ describe('RepoTab', () => { }); it('renders a tooltip', () => { - expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain('Locked by testuser'); + expect( + vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle, + ).toContain('Locked by testuser'); }); }); describe('methods', () => { describe('closeTab', () => { - it('closes tab if file has changed', (done) => { + it('closes tab if file has changed', done => { const tab = file(); tab.changed = true; tab.opened = true; @@ -140,7 +142,7 @@ describe('RepoTab', () => { }); }); - it('closes tab when clicking close btn', (done) => { + it('closes tab when clicking close btn', done => { const tab = file('lose'); tab.opened = true; vm = createComponent({ diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js index 40834f230a8..ceb0416aff8 100644 --- a/spec/javascripts/ide/components/repo_tabs_spec.js +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import repoTabs from 'ee/ide/components/repo_tabs.vue'; +import repoTabs from '~/ide/components/repo_tabs.vue'; import createComponent from '../../helpers/vue_mount_component_helper'; import { file } from '../helpers'; diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js index 67f9eaff44a..98db6defc7a 100644 --- a/spec/javascripts/ide/helpers.js +++ b/spec/javascripts/ide/helpers.js @@ -1,8 +1,8 @@ -import { decorateData } from 'ee/ide/stores/utils'; -import state from 'ee/ide/stores/state'; -import commitState from 'ee/ide/stores/modules/commit/state'; +import { decorateData } from '~/ide/stores/utils'; +import state from '~/ide/stores/state'; +import commitState from '~/ide/stores/modules/commit/state'; -export const resetStore = (store) => { +export const resetStore = store => { const newState = { ...state(), commit: commitState(), @@ -10,12 +10,13 @@ export const resetStore = (store) => { store.replaceState(newState); }; -export const file = (name = 'name', id = name, type = '') => decorateData({ - id, - type, - icon: 'icon', - url: 'url', - name, - path: name, - lastCommit: {}, -}); +export const file = (name = 'name', id = name, type = '') => + decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + lastCommit: {}, + }); diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js index 677986aff91..af12ca15369 100644 --- a/spec/javascripts/ide/lib/common/disposable_spec.js +++ b/spec/javascripts/ide/lib/common/disposable_spec.js @@ -1,4 +1,4 @@ -import Disposable from 'ee/ide/lib/common/disposable'; +import Disposable from '~/ide/lib/common/disposable'; describe('Multi-file editor library disposable class', () => { let instance; diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js index 7a1fab0f74d..4381f6fcfd0 100644 --- a/spec/javascripts/ide/lib/common/model_manager_spec.js +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -1,13 +1,13 @@ /* global monaco */ -import eventHub from 'ee/ide/eventhub'; -import monacoLoader from 'ee/ide/monaco_loader'; -import ModelManager from 'ee/ide/lib/common/model_manager'; +import eventHub from '~/ide/eventhub'; +import monacoLoader from '~/ide/monaco_loader'; +import ModelManager from '~/ide/lib/common/model_manager'; import { file } from '../../helpers'; describe('Multi-file editor library model manager', () => { let instance; - beforeEach((done) => { + beforeEach(done => { monacoLoader(['vs/editor/editor.main'], () => { instance = new ModelManager(monaco); @@ -55,7 +55,10 @@ describe('Multi-file editor library model manager', () => { instance.addModel(f); - expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.path}`, + jasmine.anything(), + ); }); }); @@ -99,7 +102,10 @@ describe('Multi-file editor library model manager', () => { instance.removeCachedModel(f); - expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.path}`, + jasmine.anything(), + ); }); }); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js index dd9e4946883..adc6a93c06b 100644 --- a/spec/javascripts/ide/lib/common/model_spec.js +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -1,13 +1,13 @@ /* global monaco */ -import eventHub from 'ee/ide/eventhub'; -import monacoLoader from 'ee/ide/monaco_loader'; -import Model from 'ee/ide/lib/common/model'; +import eventHub from '~/ide/eventhub'; +import monacoLoader from '~/ide/monaco_loader'; +import Model from '~/ide/lib/common/model'; import { file } from '../../helpers'; describe('Multi-file editor library model', () => { let model; - beforeEach((done) => { + beforeEach(done => { spyOn(eventHub, '$on').and.callThrough(); monacoLoader(['vs/editor/editor.main'], () => { @@ -27,7 +27,10 @@ describe('Multi-file editor library model', () => { }); it('adds eventHub listener', () => { - expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.path}`, + jasmine.anything(), + ); }); describe('path', () => { @@ -64,7 +67,7 @@ describe('Multi-file editor library model', () => { expect(model.events.keys().next().value).toBe('path'); }); - it('calls callback on change', (done) => { + it('calls callback on change', done => { const spy = jasmine.createSpy(); model.onChange(spy); @@ -101,7 +104,10 @@ describe('Multi-file editor library model', () => { model.dispose(); - expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.path}`, + jasmine.anything(), + ); }); }); }); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js index 63e4282d4df..092170d086a 100644 --- a/spec/javascripts/ide/lib/decorations/controller_spec.js +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -1,8 +1,8 @@ /* global monaco */ -import monacoLoader from 'ee/ide/monaco_loader'; -import editor from 'ee/ide/lib/editor'; -import DecorationsController from 'ee/ide/lib/decorations/controller'; -import Model from 'ee/ide/lib/common/model'; +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import Model from '~/ide/lib/common/model'; import { file } from '../../helpers'; describe('Multi-file editor library decorations controller', () => { @@ -10,7 +10,7 @@ describe('Multi-file editor library decorations controller', () => { let controller; let model; - beforeEach((done) => { + beforeEach(done => { monacoLoader(['vs/editor/editor.main'], () => { editorInstance = editor.create(monaco); editorInstance.createInstance(document.createElement('div')); @@ -36,7 +36,9 @@ describe('Multi-file editor library decorations controller', () => { }); it('returns decorations by model URL', () => { - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); const decorations = controller.getAllDecorationsForModel(model); @@ -46,20 +48,28 @@ describe('Multi-file editor library decorations controller', () => { describe('addDecorations', () => { it('caches decorations in a new map', () => { - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); expect(controller.decorations.size).toBe(1); }); it('does not create new cache model', () => { - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue2' }, + ]); expect(controller.decorations.size).toBe(1); }); it('caches decorations by model URL', () => { - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); expect(controller.decorations.size).toBe(1); expect(controller.decorations.keys().next().value).toBe('path'); @@ -68,7 +78,9 @@ describe('Multi-file editor library decorations controller', () => { it('calls decorate method', () => { spyOn(controller, 'decorate'); - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); expect(controller.decorate).toHaveBeenCalled(); }); @@ -80,7 +92,10 @@ describe('Multi-file editor library decorations controller', () => { controller.decorate(model); - expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith( + [], + [], + ); }); it('caches decorations', () => { @@ -102,7 +117,9 @@ describe('Multi-file editor library decorations controller', () => { describe('dispose', () => { it('clears cached decorations', () => { - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); controller.dispose(); @@ -110,7 +127,9 @@ describe('Multi-file editor library decorations controller', () => { }); it('clears cached editorDecorations', () => { - controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); controller.dispose(); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js index 90216f8b07e..c8f3e9f4830 100644 --- a/spec/javascripts/ide/lib/diff/controller_spec.js +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -1,10 +1,13 @@ /* global monaco */ -import monacoLoader from 'ee/ide/monaco_loader'; -import editor from 'ee/ide/lib/editor'; -import ModelManager from 'ee/ide/lib/common/model_manager'; -import DecorationsController from 'ee/ide/lib/decorations/controller'; -import DirtyDiffController, { getDiffChangeType, getDecorator } from 'ee/ide/lib/diff/controller'; -import { computeDiff } from 'ee/ide/lib/diff/diff'; +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import ModelManager from '~/ide/lib/common/model_manager'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import DirtyDiffController, { + getDiffChangeType, + getDecorator, +} from '~/ide/lib/diff/controller'; +import { computeDiff } from '~/ide/lib/diff/diff'; import { file } from '../../helpers'; describe('Multi-file editor library dirty diff controller', () => { @@ -14,7 +17,7 @@ describe('Multi-file editor library dirty diff controller', () => { let decorationsController; let model; - beforeEach((done) => { + beforeEach(done => { monacoLoader(['vs/editor/editor.main'], () => { editorInstance = editor.create(monaco); editorInstance.createInstance(document.createElement('div')); @@ -38,7 +41,7 @@ describe('Multi-file editor library dirty diff controller', () => { }); describe('getDiffChangeType', () => { - ['added', 'removed', 'modified'].forEach((type) => { + ['added', 'removed', 'modified'].forEach(type => { it(`returns ${type}`, () => { const change = { [type]: true, @@ -50,15 +53,15 @@ describe('Multi-file editor library dirty diff controller', () => { }); describe('getDecorator', () => { - ['added', 'removed', 'modified'].forEach((type) => { + ['added', 'removed', 'modified'].forEach(type => { it(`returns with linesDecorationsClassName for ${type}`, () => { const change = { [type]: true, }; - expect( - getDecorator(change).options.linesDecorationsClassName, - ).toBe(`dirty-diff dirty-diff-${type}`); + expect(getDecorator(change).options.linesDecorationsClassName).toBe( + `dirty-diff dirty-diff-${type}`, + ); }); it('returns with line numbers', () => { @@ -118,7 +121,9 @@ describe('Multi-file editor library dirty diff controller', () => { controller.reDecorate(model); - expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model); + expect(controller.decorationsController.decorate).toHaveBeenCalledWith( + model, + ); }); }); @@ -128,23 +133,33 @@ describe('Multi-file editor library dirty diff controller', () => { controller.decorate({ data: { changes: [], path: 'path' } }); - expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); + expect( + controller.decorationsController.addDecorations, + ).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); }); it('adds decorations into editor', () => { - const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations'); - - controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } }); - - expect(spy).toHaveBeenCalledWith([], [{ - range: new monaco.Range( - 1, 1, 1, 1, - ), - options: { - isWholeLine: true, - linesDecorationsClassName: 'dirty-diff dirty-diff-modified', - }, - }]); + const spy = spyOn( + controller.decorationsController.editor.instance, + 'deltaDecorations', + ); + + controller.decorate({ + data: { changes: computeDiff('123', '1234'), path: 'path' }, + }); + + expect(spy).toHaveBeenCalledWith( + [], + [ + { + range: new monaco.Range(1, 1, 1, 1), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }, + ], + ); }); }); @@ -166,11 +181,16 @@ describe('Multi-file editor library dirty diff controller', () => { }); it('removes worker event listener', () => { - spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough(); + spyOn( + controller.dirtyDiffWorker, + 'removeEventListener', + ).and.callThrough(); controller.dispose(); - expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything()); + expect( + controller.dirtyDiffWorker.removeEventListener, + ).toHaveBeenCalledWith('message', jasmine.anything()); }); }); }); diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js index 3bdd0a77e40..57f3ac3d365 100644 --- a/spec/javascripts/ide/lib/diff/diff_spec.js +++ b/spec/javascripts/ide/lib/diff/diff_spec.js @@ -1,4 +1,4 @@ -import { computeDiff } from 'ee/ide/lib/diff/diff'; +import { computeDiff } from '~/ide/lib/diff/diff'; describe('Multi-file editor library diff calculator', () => { describe('computeDiff', () => { diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js index b974a6befd3..d149a883166 100644 --- a/spec/javascripts/ide/lib/editor_options_spec.js +++ b/spec/javascripts/ide/lib/editor_options_spec.js @@ -1,4 +1,4 @@ -import editorOptions from 'ee/ide/lib/editor_options'; +import editorOptions from '~/ide/lib/editor_options'; describe('Multi-file editor library editor options', () => { it('returns an array', () => { diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js index 76869bbc7ce..d6df35c90e8 100644 --- a/spec/javascripts/ide/lib/editor_spec.js +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -1,6 +1,6 @@ /* global monaco */ -import monacoLoader from 'ee/ide/monaco_loader'; -import editor from 'ee/ide/lib/editor'; +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; import { file } from '../helpers'; describe('Multi-file editor library', () => { diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js index 43bc256e718..7ab315aa8c8 100644 --- a/spec/javascripts/ide/monaco_loader_spec.js +++ b/spec/javascripts/ide/monaco_loader_spec.js @@ -1,13 +1,15 @@ import monacoContext from 'monaco-editor/dev/vs/loader'; -import monacoLoader from 'ee/ide/monaco_loader'; +import monacoLoader from '~/ide/monaco_loader'; describe('MonacoLoader', () => { it('calls require.config and exports require', () => { - expect(monacoContext.require.getConfig()).toEqual(jasmine.objectContaining({ - paths: { - vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase - }, - })); + expect(monacoContext.require.getConfig()).toEqual( + jasmine.objectContaining({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + }), + ); expect(monacoLoader).toBe(monacoContext.require); }); }); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js index 55563e29d7f..5b7c8365641 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import service from 'ee/ide/services'; -import router from 'ee/ide/ide_router'; -import eventHub from 'ee/ide/eventhub'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import eventHub from '~/ide/eventhub'; import { file, resetStore } from '../../helpers'; describe('Multi-file store file actions', () => { diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js index dba4bc10f9d..381f038067b 100644 --- a/spec/javascripts/ide/stores/actions/tree_spec.js +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import store from 'ee/ide/stores'; -import service from 'ee/ide/services'; -import router from 'ee/ide/ide_router'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; import { file, resetStore } from '../../helpers'; describe('Multi-file store tree actions', () => { @@ -35,26 +35,32 @@ describe('Multi-file store tree actions', () => { describe('getFiles', () => { beforeEach(() => { - spyOn(service, 'getFiles').and.returnValue(Promise.resolve({ - json: () => Promise.resolve([ - 'file.txt', - 'folder/fileinfolder.js', - 'folder/subfolder/fileinsubfolder.js', - ]), - })); + spyOn(service, 'getFiles').and.returnValue( + Promise.resolve({ + json: () => + Promise.resolve([ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]), + }), + ); }); - it('calls service getFiles', (done) => { - store.dispatch('getFiles', basicCallParameters) - .then(() => { - expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + it('calls service getFiles', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); - done(); - }).catch(done.fail); + done(); + }) + .catch(done.fail); }); - it('adds data into tree', (done) => { - store.dispatch('getFiles', basicCallParameters) + it('adds data into tree', done => { + store + .dispatch('getFiles', basicCallParameters) .then(() => { projectTree = store.state.trees['abcproject/master']; expect(projectTree.tree.length).toBe(2); @@ -62,10 +68,13 @@ describe('Multi-file store tree actions', () => { expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); expect(projectTree.tree[1].type).toBe('blob'); expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe( + 'fileinsubfolder.js', + ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); }); @@ -77,30 +86,38 @@ describe('Multi-file store tree actions', () => { store.state.entries[tree.path] = tree; }); - it('toggles the tree open', (done) => { - store.dispatch('toggleTreeOpen', tree.path).then(() => { - expect(tree.opened).toBeTruthy(); + it('toggles the tree open', done => { + store + .dispatch('toggleTreeOpen', tree.path) + .then(() => { + expect(tree.opened).toBeTruthy(); - done(); - }).catch(done.fail); + done(); + }) + .catch(done.fail); }); }); describe('getLastCommitData', () => { beforeEach(() => { - spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({ - headers: { - 'more-logs-url': null, - }, - json: () => Promise.resolve([{ - type: 'tree', - file_name: 'testing', - commit: { - message: 'commit message', - authored_date: '123', + spyOn(service, 'getTreeLastCommit').and.returnValue( + Promise.resolve({ + headers: { + 'more-logs-url': null, }, - }]), - })); + json: () => + Promise.resolve([ + { + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }, + ]), + }), + ); store.state.trees['abcproject/mybranch'] = { tree: [], @@ -111,35 +128,45 @@ describe('Multi-file store tree actions', () => { projectTree.lastCommitPath = 'lastcommitpath'; }); - it('calls service with lastCommitPath', (done) => { - store.dispatch('getLastCommitData', projectTree) + it('calls service with lastCommitPath', done => { + store + .dispatch('getLastCommitData', projectTree) .then(() => { - expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); + expect(service.getTreeLastCommit).toHaveBeenCalledWith( + 'lastcommitpath', + ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); - it('updates trees last commit data', (done) => { - store.dispatch('getLastCommitData', projectTree) - .then(Vue.nextTick) + it('updates trees last commit data', done => { + store + .dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) .then(() => { expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); - it('does not update entry if not found', (done) => { + it('does not update entry if not found', done => { projectTree.tree[0].name = 'a'; - store.dispatch('getLastCommitData', projectTree) + store + .dispatch('getLastCommitData', projectTree) .then(Vue.nextTick) .then(() => { - expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); + expect(projectTree.tree[0].lastCommit.message).not.toBe( + 'commit message', + ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index 0da1226c7aa..cec572f4507 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -1,6 +1,6 @@ import * as urlUtils from '~/lib/utils/url_utility'; -import store from 'ee/ide/stores'; -import router from 'ee/ide/ide_router'; +import store from '~/ide/stores'; +import router from '~/ide/ide_router'; import { resetStore, file } from '../helpers'; describe('Multi-file store actions', () => { diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index 2fb69339915..a613f3a21cc 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -1,5 +1,5 @@ -import * as getters from 'ee/ide/stores/getters'; -import state from 'ee/ide/stores/state'; +import * as getters from '~/ide/stores/getters'; +import state from '~/ide/stores/state'; import { file } from '../helpers'; describe('Multi-file store getters', () => { diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js index 0aef29f77e3..90ded940227 100644 --- a/spec/javascripts/ide/stores/modules/commit/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -1,9 +1,9 @@ -import store from 'ee/ide/stores'; -import service from 'ee/ide/services'; -import router from 'ee/ide/ide_router'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; import * as urlUtils from '~/lib/utils/url_utility'; -import eventHub from 'ee/ide/eventhub'; -import * as consts from 'ee/ide/stores/modules/commit/constants'; +import eventHub from '~/ide/eventhub'; +import * as consts from '~/ide/stores/modules/commit/constants'; import { resetStore, file } from 'spec/ide/helpers'; describe('IDE commit module actions', () => { @@ -16,8 +16,9 @@ describe('IDE commit module actions', () => { }); describe('updateCommitMessage', () => { - it('updates store with new commit message', (done) => { - store.dispatch('commit/updateCommitMessage', 'testing') + it('updates store with new commit message', done => { + store + .dispatch('commit/updateCommitMessage', 'testing') .then(() => { expect(store.state.commit.commitMessage).toBe('testing'); }) @@ -27,10 +28,11 @@ describe('IDE commit module actions', () => { }); describe('discardDraft', () => { - it('resets commit message to blank', (done) => { + it('resets commit message to blank', done => { store.state.commit.commitMessage = 'testing'; - store.dispatch('commit/discardDraft') + store + .dispatch('commit/discardDraft') .then(() => { expect(store.state.commit.commitMessage).not.toBe('testing'); }) @@ -40,8 +42,9 @@ describe('IDE commit module actions', () => { }); describe('updateCommitAction', () => { - it('updates store with new commit action', (done) => { - store.dispatch('commit/updateCommitAction', '1') + it('updates store with new commit action', done => { + store + .dispatch('commit/updateCommitAction', '1') .then(() => { expect(store.state.commit.commitAction).toBe('1'); }) @@ -51,8 +54,9 @@ describe('IDE commit module actions', () => { }); describe('updateBranchName', () => { - it('updates store with new branch name', (done) => { - store.dispatch('commit/updateBranchName', 'branch-name') + it('updates store with new branch name', done => { + store + .dispatch('commit/updateBranchName', 'branch-name') .then(() => { expect(store.state.commit.newBranchName).toBe('branch-name'); }) @@ -73,8 +77,9 @@ describe('IDE commit module actions', () => { }); }); - it('updates commit message with short_id', (done) => { - store.dispatch('commit/setLastCommitMessage', { short_id: '123' }) + it('updates commit message with short_id', done => { + store + .dispatch('commit/setLastCommitMessage', { short_id: '123' }) .then(() => { expect(store.state.lastCommitMsg).toContain( 'Your changes have been committed. Commit 123', @@ -84,16 +89,19 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('updates commit message with stats', (done) => { - store.dispatch('commit/setLastCommitMessage', { - short_id: '123', - stats: { - additions: '1', - deletions: '2', - }, - }) + it('updates commit message with stats', done => { + store + .dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) .then(() => { - expect(store.state.lastCommitMsg).toBe('Your changes have been committed. Commit 123 with 1 additions, 2 deletions.'); + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.', + ); }) .then(done) .catch(done.fail); @@ -113,31 +121,40 @@ describe('IDE commit module actions', () => { }; }); - it('calls service', (done) => { - spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ - data: { - commit: { id: '123' }, - }, - })); + it('calls service', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); - store.dispatch('commit/checkCommitStatus') + store + .dispatch('commit/checkCommitStatus') .then(() => { - expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); + expect(service.getBranchData).toHaveBeenCalledWith( + 'abcproject', + 'master', + ); done(); }) .catch(done.fail); }); - it('returns true if current ref does not equal returned ID', (done) => { - spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ - data: { - commit: { id: '123' }, - }, - })); + it('returns true if current ref does not equal returned ID', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); - store.dispatch('commit/checkCommitStatus') - .then((val) => { + store + .dispatch('commit/checkCommitStatus') + .then(val => { expect(val).toBeTruthy(); done(); @@ -145,15 +162,18 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('returns false if current ref equals returned ID', (done) => { - spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ - data: { - commit: { id: '1' }, - }, - })); + it('returns false if current ref equals returned ID', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '1' }, + }, + }), + ); - store.dispatch('commit/checkCommitStatus') - .then((val) => { + store + .dispatch('commit/checkCommitStatus') + .then(val => { expect(val).toBeFalsy(); done(); @@ -198,16 +218,17 @@ describe('IDE commit module actions', () => { }); store.state.openFiles = store.state.changedFiles; - store.state.changedFiles.forEach((changedFile) => { + store.state.changedFiles.forEach(changedFile => { store.state.entries[changedFile.path] = changedFile; }); }); - it('updates stores working reference', (done) => { - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + it('updates stores working reference', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { expect( store.state.projects.abcproject.branches.master.workingReference, @@ -217,13 +238,14 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('resets all files changed status', (done) => { - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + it('resets all files changed status', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { - store.state.openFiles.forEach((entry) => { + store.state.openFiles.forEach(entry => { expect(entry.changed).toBeFalsy(); }); }) @@ -231,11 +253,12 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('removes all changed files', (done) => { - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + it('removes all changed files', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { expect(store.state.changedFiles.length).toBe(0); }) @@ -243,11 +266,12 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('sets files commit data', (done) => { - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + it('sets files commit data', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { expect(f.lastCommit.message).toBe(data.message); }) @@ -255,11 +279,12 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('updates raw content for changed file', (done) => { - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + it('updates raw content for changed file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { expect(f.raw).toBe(f.content); }) @@ -267,25 +292,30 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('emits changed event for file', (done) => { - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + it('emits changed event for file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.path}`, f.content); + expect(eventHub.$emit).toHaveBeenCalledWith( + `editor.update.model.content.${f.path}`, + f.content, + ); }) .then(done) .catch(done.fail); }); - it('pushes route to new branch if commitAction is new branch', (done) => { + it('pushes route to new branch if commitAction is new branch', done => { store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { expect(router.push).toHaveBeenCalledWith( `/project/abcproject/blob/master/${f.path}`, @@ -295,15 +325,18 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('resets stores commit actions', (done) => { + it('resets stores commit actions', done => { store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; - store.dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) .then(() => { - expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH); + expect(store.state.commit.commitAction).not.toBe( + consts.COMMIT_TO_NEW_BRANCH, + ); }) .then(done) .catch(done.fail); @@ -330,7 +363,7 @@ describe('IDE commit module actions', () => { store.state.changedFiles[0].active = true; store.state.openFiles = store.state.changedFiles; - store.state.openFiles.forEach((f) => { + store.state.openFiles.forEach(f => { store.state.entries[f.path] = f; }); @@ -344,106 +377,128 @@ describe('IDE commit module actions', () => { describe('success', () => { beforeEach(() => { - spyOn(service, 'commit').and.returnValue(Promise.resolve({ - data: { - id: '123456', - short_id: '123', - message: 'test message', - committed_date: 'date', - stats: { - additions: '1', - deletions: '2', + spyOn(service, 'commit').and.returnValue( + Promise.resolve({ + data: { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, }, - }, - })); + }), + ); }); - it('calls service', (done) => { - store.dispatch('commit/commitChanges') + it('calls service', done => { + store + .dispatch('commit/commitChanges') .then(() => { expect(service.commit).toHaveBeenCalledWith('abcproject', { branch: jasmine.anything(), commit_message: 'testing 123', - actions: [{ - action: 'update', - file_path: jasmine.anything(), - content: jasmine.anything(), - encoding: jasmine.anything(), - }], + actions: [ + { + action: 'update', + file_path: jasmine.anything(), + content: jasmine.anything(), + encoding: jasmine.anything(), + }, + ], start_branch: 'master', }); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); - it('pushes router to new route', (done) => { - store.dispatch('commit/commitChanges') + it('pushes router to new route', done => { + store + .dispatch('commit/commitChanges') .then(() => { expect(router.push).toHaveBeenCalledWith( - `/project/${store.state.currentProjectId}/blob/${store.getters['commit/newBranchName']}/changed`, + `/project/${store.state.currentProjectId}/blob/${ + store.getters['commit/newBranchName'] + }/changed`, ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); - it('sets last Commit Msg', (done) => { - store.dispatch('commit/commitChanges') + it('sets last Commit Msg', done => { + store + .dispatch('commit/commitChanges') .then(() => { expect(store.state.lastCommitMsg).toBe( 'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.', ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); - it('adds commit data to changed files', (done) => { - store.dispatch('commit/commitChanges') + it('adds commit data to changed files', done => { + store + .dispatch('commit/commitChanges') .then(() => { - expect(store.state.openFiles[0].lastCommit.message).toBe('test message'); + expect(store.state.openFiles[0].lastCommit.message).toBe( + 'test message', + ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); - it('redirects to new merge request page', (done) => { + it('redirects to new merge request page', done => { spyOn(eventHub, '$on'); store.state.commit.commitAction = '3'; - store.dispatch('commit/commitChanges') + store + .dispatch('commit/commitChanges') .then(() => { expect(urlUtils.visitUrl).toHaveBeenCalledWith( - `webUrl/merge_requests/new?merge_request[source_branch]=${store.getters['commit/newBranchName']}&merge_request[target_branch]=master`, + `webUrl/merge_requests/new?merge_request[source_branch]=${ + store.getters['commit/newBranchName'] + }&merge_request[target_branch]=master`, ); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); }); describe('failed', () => { beforeEach(() => { - spyOn(service, 'commit').and.returnValue(Promise.resolve({ - data: { - message: 'failed message', - }, - })); + spyOn(service, 'commit').and.returnValue( + Promise.resolve({ + data: { + message: 'failed message', + }, + }), + ); }); - it('shows failed message', (done) => { - store.dispatch('commit/commitChanges') + it('shows failed message', done => { + store + .dispatch('commit/commitChanges') .then(() => { const alert = document.querySelector('.flash-container'); - expect(alert.textContent.trim()).toBe( - 'failed message', - ); + expect(alert.textContent.trim()).toBe('failed message'); done(); - }).catch(done.fail); + }) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js index b1467bcf3c7..e396284ec2c 100644 --- a/spec/javascripts/ide/stores/modules/commit/getters_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -1,6 +1,6 @@ -import commitState from 'ee/ide/stores/modules/commit/state'; -import * as consts from 'ee/ide/stores/modules/commit/constants'; -import * as getters from 'ee/ide/stores/modules/commit/getters'; +import commitState from '~/ide/stores/modules/commit/state'; +import * as consts from '~/ide/stores/modules/commit/constants'; +import * as getters from '~/ide/stores/modules/commit/getters'; describe('IDE commit module getters', () => { let state; @@ -38,26 +38,34 @@ describe('IDE commit module getters', () => { }; it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { - expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeFalsy(); + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeFalsy(); }); it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { rootState.changedFiles.length = 0; - expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); }); it('returns true when discardDraftButtonDisabled is true', () => { localGetters.discardDraftButtonDisabled = true; - expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); }); it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { localGetters.discardDraftButtonDisabled = false; rootState.changedFiles.length = 0; - expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); }); }); @@ -65,7 +73,9 @@ describe('IDE commit module getters', () => { it('includes username, currentBranchId, patch & random number', () => { gon.current_username = 'username'; - const branch = getters.newBranchName(state, null, { currentBranchId: 'testing' }); + const branch = getters.newBranchName(state, null, { + currentBranchId: 'testing', + }); expect(branch).toMatch(/username-testing-patch-\d{5}$/); }); @@ -89,7 +99,7 @@ describe('IDE commit module getters', () => { expect(getters.branchName(state, null, rootState)).toBe('master'); }); - ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach((type) => { + ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach(type => { describe(type, () => { beforeEach(() => { Object.assign(state, { @@ -98,7 +108,9 @@ describe('IDE commit module getters', () => { }); it('uses newBranchName when not empty', () => { - expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName'); + expect(getters.branchName(state, localGetters, rootState)).toBe( + 'state-newBranchName', + ); }); it('uses getters newBranchName when state newBranchName is empty', () => { @@ -106,7 +118,9 @@ describe('IDE commit module getters', () => { newBranchName: '', }); - expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName'); + expect(getters.branchName(state, localGetters, rootState)).toBe( + 'newBranchName', + ); }); }); }); diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js index fa43e3d9d02..5de7a281d34 100644 --- a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js @@ -1,5 +1,5 @@ -import commitState from 'ee/ide/stores/modules/commit/state'; -import mutations from 'ee/ide/stores/modules/commit/mutations'; +import commitState from '~/ide/stores/modules/commit/state'; +import mutations from '~/ide/stores/modules/commit/mutations'; describe('IDE commit module mutations', () => { let state; diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js index 1601769144a..a7167537ef2 100644 --- a/spec/javascripts/ide/stores/mutations/branch_spec.js +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -1,5 +1,5 @@ -import mutations from 'ee/ide/stores/mutations/branch'; -import state from 'ee/ide/stores/state'; +import mutations from '~/ide/stores/mutations/branch'; +import state from '~/ide/stores/state'; describe('Multi-file store branch mutations', () => { let localState; diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js index 944639c3336..131380248e8 100644 --- a/spec/javascripts/ide/stores/mutations/file_spec.js +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -1,5 +1,5 @@ -import mutations from 'ee/ide/stores/mutations/file'; -import state from 'ee/ide/stores/state'; +import mutations from '~/ide/stores/mutations/file'; +import state from '~/ide/stores/state'; import { file } from '../../helpers'; describe('Multi-file store file mutations', () => { diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js index e321eff8749..e6c085eaff6 100644 --- a/spec/javascripts/ide/stores/mutations/tree_spec.js +++ b/spec/javascripts/ide/stores/mutations/tree_spec.js @@ -1,5 +1,5 @@ -import mutations from 'ee/ide/stores/mutations/tree'; -import state from 'ee/ide/stores/state'; +import mutations from '~/ide/stores/mutations/tree'; +import state from '~/ide/stores/state'; import { file } from '../../helpers'; describe('Multi-file store tree mutations', () => { @@ -26,15 +26,17 @@ describe('Multi-file store tree mutations', () => { }); describe('SET_DIRECTORY_DATA', () => { - const data = [{ - name: 'tree', - }, - { - name: 'submodule', - }, - { - name: 'blob', - }]; + const data = [ + { + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }, + ]; it('adds directory data', () => { localState.trees['project/master'] = { diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js index e0d214010d5..38162a470ad 100644 --- a/spec/javascripts/ide/stores/mutations_spec.js +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -1,5 +1,5 @@ -import mutations from 'ee/ide/stores/mutations'; -import state from 'ee/ide/stores/state'; +import mutations from '~/ide/stores/mutations'; +import state from '~/ide/stores/state'; import { file } from '../helpers'; describe('Multi-file store mutations', () => { diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js index a473d3a4294..f38ac6dd82f 100644 --- a/spec/javascripts/ide/stores/utils_spec.js +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -1,4 +1,4 @@ -import * as utils from 'ee/ide/stores/utils'; +import * as utils from '~/ide/stores/utils'; describe('Multi-file store utils', () => { describe('setPageTitle', () => { @@ -13,11 +13,14 @@ describe('Multi-file store utils', () => { let localState; beforeEach(() => { - localState = [{ - path: '1', - }, { - path: '2', - }]; + localState = [ + { + path: '1', + }, + { + path: '2', + }, + ]; }); it('finds in the index of an entry by path', () => { @@ -34,13 +37,16 @@ describe('Multi-file store utils', () => { beforeEach(() => { localState = { - tree: [{ - type: 'tree', - name: 'test', - }, { - type: 'blob', - name: 'file', - }], + tree: [ + { + type: 'tree', + name: 'test', + }, + { + type: 'blob', + name: 'file', + }, + ], }; }); -- cgit v1.2.1 From 82dbb903cb697a628ccdf75860b34c8d87baa7e3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 20 Mar 2018 14:39:51 +0000 Subject: fixed SCSS lint --- app/assets/stylesheets/framework/images.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 93cb83b3a4c..df1cafc9f8e 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -42,24 +42,31 @@ svg { &.s8 { @include svg-size(8px); } + &.s12 { @include svg-size(12px); } + &.s16 { @include svg-size(16px); } + &.s18 { @include svg-size(18px); } + &.s24 { @include svg-size(24px); } + &.s32 { @include svg-size(32px); } + &.s48 { @include svg-size(48px); } + &.s72 { @include svg-size(72px); } -- cgit v1.2.1 From e8b85811ab91aea8b2eed325cfdaac16b347b302 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 20 Mar 2018 15:00:58 +0000 Subject: remove un-used IDE helper module --- app/helpers/ide_helper.rb | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 app/helpers/ide_helper.rb diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb deleted file mode 100644 index f090ae71269..00000000000 --- a/app/helpers/ide_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -module IdeHelper - def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) - return unless blob = readable_blob(options, path, project, ref) - - common_classes = "btn js-edit-ide #{options[:extra_class]}" - - edit_button_tag(blob, - common_classes, - _('Web IDE'), - ide_edit_path(project, ref, path, options), - project, - ref) - end -end -- cgit v1.2.1 From 6b0dc00ba1f821fadc0d6da571ec6b759a832bea Mon Sep 17 00:00:00 2001 From: James Ramsay Date: Mon, 19 Mar 2018 10:07:40 -0400 Subject: Update merge request maintainer access docs --- doc/user/project/merge_requests/maintainer_access.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/doc/user/project/merge_requests/maintainer_access.md b/doc/user/project/merge_requests/maintainer_access.md index 7feccc28f6b..c9763a3fe02 100644 --- a/doc/user/project/merge_requests/maintainer_access.md +++ b/doc/user/project/merge_requests/maintainer_access.md @@ -1,12 +1,17 @@ # Allow maintainer pushes for merge requests across forks +> [Introduced][ce-17395] in GitLab 10.6. + This feature is available for merge requests across forked projects that are -publicly accessible. It makes it easier for maintainers of projects to collaborate -on merge requests across forks. +publicly accessible. It makes it easier for maintainers of projects to +collaborate on merge requests across forks. -When enabling this feature for a merge request, you give can give members with push access to the target project rights to edit files on the source branch of the merge request. +When enabled for a merge request, members with merge access to the target +branch of the project will be granted write permissions to the source branch +of the merge request. -The feature can only be enabled by users who already have push access to the source project. And only lasts while the merge request is open. +The feature can only be enabled by users who already have push access to the +source project, and only lasts while the merge request is open. Enable this functionality while creating a merge request: -- cgit v1.2.1 From a61761908fde2f032f00f07d0ccc479177db6d3f Mon Sep 17 00:00:00 2001 From: Tomasz Maczukin Date: Mon, 19 Mar 2018 14:17:46 +0100 Subject: Prevent auto-retry AccessDenied error from stopping transition to failed --- app/models/ci/build.rb | 6 +++++- spec/models/ci/build_spec.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index f8a3600e863..4edcfd02157 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -140,7 +140,11 @@ module Ci next if build.retries_max.zero? if build.retries_count < build.retries_max - Ci::Build.retry(build, build.user) + begin + Ci::Build.retry(build, build.user) + rescue Gitlab::Access::AccessDeniedError => ex + Rails.logger.error "Unable to auto-retry job #{build.id}: #{ex}" + end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 01203ff44c8..820827d078a 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -2065,6 +2065,35 @@ describe Ci::Build do subject.drop! end + + context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do + let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) } + + before do + allow_any_instance_of(Ci::RetryBuildService) + .to receive(:execute) + .with(subject) + .and_raise(Gitlab::Access::AccessDeniedError) + allow(Rails.logger).to receive(:error) + end + + it 'handles raised exception' do + expect { subject.drop! }.not_to raise_exception(Gitlab::Access::AccessDeniedError) + end + + it 'logs the error' do + subject.drop! + + expect(Rails.logger) + .to have_received(:error) + .with(a_string_matching("Unable to auto-retry job #{subject.id}")) + end + + it 'fails the job' do + subject.drop! + expect(subject.failed?).to be_truthy + end + end end context 'when build is not configured to be retried' do -- cgit v1.2.1 From 9c9c0484f61d6c8198c060c78fcfc9458c16955f Mon Sep 17 00:00:00 2001 From: Tomasz Maczukin Date: Mon, 19 Mar 2018 15:01:11 +0100 Subject: Add CHANGELOG entry --- changelogs/unreleased/fix-ci-job-auto-retry.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/fix-ci-job-auto-retry.yml diff --git a/changelogs/unreleased/fix-ci-job-auto-retry.yml b/changelogs/unreleased/fix-ci-job-auto-retry.yml new file mode 100644 index 00000000000..442126461f0 --- /dev/null +++ b/changelogs/unreleased/fix-ci-job-auto-retry.yml @@ -0,0 +1,5 @@ +--- +title: Prevent auto-retry AccessDenied error from stopping transition to failed +merge_request: 17862 +author: +type: fixed -- cgit v1.2.1 From 1fff345e0d2cd6c79239e06098ec4240347b496b Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Tue, 20 Mar 2018 10:50:26 -0500 Subject: move confirm_danger_modal bindings directly into the only two pages that need it --- app/assets/javascripts/confirm_danger_modal.js | 12 +++++++++++- app/assets/javascripts/main.js | 9 --------- app/assets/javascripts/pages/groups/edit/index.js | 2 ++ app/assets/javascripts/pages/projects/edit/index.js | 2 ++ 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index c21c52b0086..1638e09132b 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { rstrip } from './lib/utils/common_utils'; -export default function initConfirmDangerModal($form, text) { +function openConfirmDangerModal($form, text) { $('.js-confirm-text').text(text || ''); $('.js-confirm-danger-input').val(''); $('#modal-confirm-danger').modal('show'); @@ -20,3 +20,13 @@ export default function initConfirmDangerModal($form, text) { }); $('.js-confirm-danger-submit').off('click').on('click', () => $form.submit()); } + +export default function initConfirmDangerModal() { + $(document).on('click', '.js-confirm-danger', (e) => { + e.preventDefault(); + const $btn = $(e.target); + const $form = $btn.closest('form'); + const text = $btn.data('confirmDangerMessage'); + openConfirmDangerModal($form, text); + }); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c867f5f3236..2c80baba10b 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -20,7 +20,6 @@ import './behaviors/'; // everything else import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; -import initConfirmDangerModal from './confirm_danger_modal'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; import initTodoToggle from './header'; @@ -213,14 +212,6 @@ document.addEventListener('DOMContentLoaded', () => { $(document).trigger('toggle.comments'); }); - $document.on('click', '.js-confirm-danger', (e) => { - e.preventDefault(); - const $btn = $(e.target); - const $form = $btn.closest('form'); - const text = $btn.data('confirmDangerMessage'); - initConfirmDangerModal($form, text); - }); - $document.on('breakpoint:change', (e, breakpoint) => { if (breakpoint === 'sm' || breakpoint === 'xs') { const $gutterIcon = $sidebarGutterToggle.find('i'); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index d44874c8741..bb91ac84ffb 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,7 +1,9 @@ import groupAvatar from '~/group_avatar'; import TransferDropdown from '~/groups/transfer_dropdown'; +import initConfirmDangerModal from '~/confirm_danger_modal'; document.addEventListener('DOMContentLoaded', () => { groupAvatar(); new TransferDropdown(); // eslint-disable-line no-new + initConfirmDangerModal(); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 064de22dfd6..be37df36be8 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -1,5 +1,6 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; +import initConfirmDangerModal from '~/confirm_danger_modal'; import ProjectNew from '../shared/project_new'; import projectAvatar from '../shared/project_avatar'; import initProjectPermissionsSettings from '../shared/permissions'; @@ -11,4 +12,5 @@ document.addEventListener('DOMContentLoaded', () => { initSettingsPanels(); projectAvatar(); initProjectPermissionsSettings(); + initConfirmDangerModal(); }); -- cgit v1.2.1 From 2467852ccefaf8af961f54cd601f6f322dfad6c4 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 20 Mar 2018 16:00:10 +0000 Subject: remove JS for cookie toggle --- app/assets/javascripts/profile/profile.js | 41 ++++++++++++++----------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 3c1bef23446..0af34657d72 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,7 +1,6 @@ /* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */ import $ from 'jquery'; -import Cookies from 'js-cookie'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import flash from '../flash'; @@ -10,7 +9,6 @@ export default class Profile { constructor({ form } = {}) { this.onSubmitForm = this.onSubmitForm.bind(this); this.form = form || $('.edit-user'); - this.newRepoActivated = Cookies.get('new_repo'); this.setRepoRadio(); this.bindEvents(); this.initAvatarGlCrop(); @@ -23,21 +21,28 @@ export default class Profile { modalCrop: '.modal-profile-crop', pickImageEl: '.js-choose-user-avatar-button', uploadImageBtn: '.js-upload-user-avatar', - modalCropImg: '.modal-profile-crop-image' + modalCropImg: '.modal-profile-crop-image', }; - this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); + this.avatarGlCrop = $('.js-user-avatar-input') + .glCrop(cropOpts) + .data('glcrop'); } bindEvents() { - $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); - $('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie); + $('.js-preferences-form').on( + 'change.preference', + 'input[type=radio]', + this.submitForm, + ); $('#user_notification_email').on('change', this.submitForm); $('#user_notified_of_own_activity').on('change', this.submitForm); this.form.on('submit', this.onSubmitForm); } submitForm() { - return $(this).parents('form').submit(); + return $(this) + .parents('form') + .submit(); } onSubmitForm(e) { @@ -59,21 +64,13 @@ export default class Profile { url: this.form.attr('action'), data: formData, }) - .then(({ data }) => flash(data.message, 'notice')) - .then(() => { - window.scrollTo(0, 0); - // Enable submit button after requests ends - self.form.find(':input[disabled]').enable(); - }) - .catch(error => flash(error.message)); - } - - setNewRepoCookie() { - if (this.value === 'off') { - Cookies.remove('new_repo'); - } else { - Cookies.set('new_repo', true, { expires_in: 365 }); - } + .then(({ data }) => flash(data.message, 'notice')) + .then(() => { + window.scrollTo(0, 0); + // Enable submit button after requests ends + self.form.find(':input[disabled]').enable(); + }) + .catch(error => flash(error.message)); } setRepoRadio() { -- cgit v1.2.1 From 85ce8c7e22f923b29d440538cbaa2736fe9d332f Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 20 Mar 2018 11:37:39 +0200 Subject: Update spec import path for vue mount component helper --- .../update-spec-import-path-for-vue-mount-component-helper.yml | 5 +++++ spec/javascripts/notes/components/diff_file_header_spec.js | 2 +- spec/javascripts/notes/components/diff_with_note_spec.js | 2 +- spec/javascripts/pages/labels/components/promote_label_modal_spec.js | 2 +- .../milestones/shared/components/promote_milestone_modal_spec.js | 2 +- spec/javascripts/vue_shared/components/markdown/toolbar_spec.js | 2 +- .../vue_shared/components/sidebar/labels_select/base_spec.js | 4 ++-- .../components/sidebar/labels_select/dropdown_button_spec.js | 4 ++-- .../components/sidebar/labels_select/dropdown_create_label_spec.js | 4 ++-- .../components/sidebar/labels_select/dropdown_footer_spec.js | 4 ++-- .../components/sidebar/labels_select/dropdown_header_spec.js | 2 +- .../components/sidebar/labels_select/dropdown_hidden_input_spec.js | 4 ++-- .../components/sidebar/labels_select/dropdown_search_input_spec.js | 2 +- .../components/sidebar/labels_select/dropdown_title_spec.js | 2 +- .../sidebar/labels_select/dropdown_value_collapsed_spec.js | 4 ++-- .../components/sidebar/labels_select/dropdown_value_spec.js | 4 ++-- 16 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml diff --git a/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml b/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml new file mode 100644 index 00000000000..9c13bfbaf6f --- /dev/null +++ b/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml @@ -0,0 +1,5 @@ +--- +title: Update spec import path for vue mount component helper +merge_request: 17880 +author: George Tsiolis +type: performance diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js index aed30a087a6..ef6d513444a 100644 --- a/spec/javascripts/notes/components/diff_file_header_spec.js +++ b/spec/javascripts/notes/components/diff_file_header_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffFileHeader from '~/notes/components/diff_file_header.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const discussionFixture = 'merge_requests/diff_discussion.json'; diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js index 7f1f4bf0bcd..f4ec7132dbd 100644 --- a/spec/javascripts/notes/components/diff_with_note_spec.js +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const discussionFixture = 'merge_requests/diff_discussion.json'; const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js index ba2e07f02f7..080158a8ee0 100644 --- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js +++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; import eventHub from '~/pages/projects/labels/event_hub'; import axios from '~/lib/utils/axios_utils'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Promote label modal', () => { let vm; diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js index bf044fe8fb5..22956929e7b 100644 --- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; import axios from '~/lib/utils/axios_utils'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Promote milestone modal', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js index 818ef0af3c2..3e708f865c8 100644 --- a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('toolbar', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js index 8daaf018396..6fe95153204 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js @@ -3,9 +3,9 @@ import Vue from 'vue'; import LabelsSelect from '~/labels_select'; import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = (config = mockConfig) => { const Component = Vue.extend(baseComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index ec63ac306d0..f25c70db125 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const componentConfig = Object.assign({}, mockConfig, { fieldName: 'label_id[]', diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index 5cb4bb6fea6..ce559fe0335 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; -import { mockSuggestedColors } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockSuggestedColors } from './mock_data'; const createComponent = (headerTitle) => { const Component = Vue.extend(dropdownCreateLabelComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js index 0f4fa716f8a..debeab25bd6 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; -import { mockConfig } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig } from './mock_data'; const createComponent = ( labelsWebUrl = mockConfig.labelsWebUrl, diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js index 325fa47c957..cdf234bb0c4 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(dropdownHeaderComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js index 703b87498c7..88733922a59 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue'; -import { mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockLabels } from './mock_data'; const createComponent = (name = 'label_id[]', label = mockLabels[0]) => { const Component = Vue.extend(dropdownHiddenInputComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js index 69e11d966c2..57608d957e7 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(dropdownSearchInputComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js index c3580933072..7c3d2711f65 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = (canEdit = true) => { const Component = Vue.extend(dropdownTitleComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 93b42795bea..39040670a87 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; -import { mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 66e0957b431..4397b00acfa 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = ( labels = mockLabels, -- cgit v1.2.1 From edf2060c6d91db079e30ed56178be2eabc352132 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 20 Mar 2018 17:00:56 +0000 Subject: fixed ide_edit_button not existing --- app/helpers/blob_helper.rb | 11 +++++++++++ app/views/projects/blob/_header.html.haml | 1 + 2 files changed, 12 insertions(+) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 5ff09b23a78..2b440e4d584 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -33,6 +33,17 @@ module BlobHelper ref) end + def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + return unless blob = readable_blob(options, path, project, ref) + + edit_button_tag(blob, + 'btn btn-default', + _('Web IDE'), + ide_edit_path(project, ref, path, options), + project, + ref) + end + def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) return unless current_user diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index f93bb02acb9..1b150ec3e5c 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -12,6 +12,7 @@ .btn-group{ role: "group" }< = edit_blob_button + = ide_edit_button - if current_user = replace_blob_link = delete_blob_link -- cgit v1.2.1 From 44fce0b0fdb5d3f8897bf71f336ad14b66a6ce91 Mon Sep 17 00:00:00 2001 From: Simon Knox Date: Tue, 20 Mar 2018 17:02:41 +0000 Subject: Resolve "Loss of input text on comments after preview" --- app/assets/javascripts/gl_form.js | 6 +- app/assets/javascripts/lib/utils/text_markdown.js | 131 +++++++++------------ ...oss-of-input-text-on-comments-after-preview.yml | 5 + spec/javascripts/lib/utils/text_markdown_spec.js | 10 +- 4 files changed, 69 insertions(+), 83 deletions(-) create mode 100644 changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 184c98813f1..9f5eba353d7 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import autosize from 'autosize'; import GfmAutoComplete from './gfm_auto_complete'; import dropzoneInput from './dropzone_input'; -import textUtils from './lib/utils/text_markdown'; +import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown'; export default class GLForm { constructor(form, enableGFM = false) { @@ -47,7 +47,7 @@ export default class GLForm { } // form and textarea event listeners this.addEventListeners(); - textUtils.init(this.form); + addMarkdownListeners(this.form); // hide discard button this.form.find('.js-note-discard').hide(); this.form.show(); @@ -86,7 +86,7 @@ export default class GLForm { clearEventListeners() { this.textarea.off('focus'); this.textarea.off('blur'); - textUtils.removeListeners(this.form); + removeMarkdownListeners(this.form); } addEventListeners() { diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 470e3e5c52e..5a16adea4dc 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -1,28 +1,25 @@ /* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ - import $ from 'jquery'; +import { insertText } from '~/lib/utils/common_utils'; -const textUtils = {}; - -textUtils.selectedText = function(text, textarea) { +function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); -}; +} -textUtils.lineBefore = function(text, textarea) { +function lineBefore(text, textarea) { var split; split = text.substring(0, textarea.selectionStart).trim().split('\n'); return split[split.length - 1]; -}; +} -textUtils.lineAfter = function(text, textarea) { +function lineAfter(text, textarea) { return text.substring(textarea.selectionEnd).trim().split('\n')[0]; -}; +} -textUtils.blockTagText = function(text, textArea, blockTag, selected) { - var lineAfter, lineBefore; - lineBefore = this.lineBefore(text, textArea); - lineAfter = this.lineAfter(text, textArea); - if (lineBefore === blockTag && lineAfter === blockTag) { +function blockTagText(text, textArea, blockTag, selected) { + const before = lineBefore(text, textArea); + const after = lineAfter(text, textArea); + if (before === blockTag && after === blockTag) { // To remove the block tag we have to select the line before & after if (blockTag != null) { textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); @@ -32,10 +29,30 @@ textUtils.blockTagText = function(text, textArea, blockTag, selected) { } else { return blockTag + "\n" + selected + "\n" + blockTag; } -}; +} -textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) { - var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; +function moveCursor(textArea, tag, wrapped, removedLastNewLine) { + var pos; + if (!textArea.setSelectionRange) { + return; + } + if (textArea.selectionStart === textArea.selectionEnd) { + if (wrapped) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } + + if (removedLastNewLine) { + pos -= 1; + } + + return textArea.setSelectionRange(pos, pos); + } +} + +export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap) { + var textToInsert, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; removedLastNewLine = false; removedFirstNewLine = false; currentLineEmpty = false; @@ -67,9 +84,9 @@ textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) { if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { if (blockTag != null && blockTag !== '') { - insertText = this.blockTagText(text, textArea, blockTag, selected); + textToInsert = blockTagText(text, textArea, blockTag, selected); } else { - insertText = selectedSplit.map(function(val) { + textToInsert = selectedSplit.map(function(val) { if (val.indexOf(tag) === 0) { return "" + (val.replace(tag, '')); } else { @@ -78,78 +95,42 @@ textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) { }).join('\n'); } } else { - insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); + textToInsert = "" + startChar + tag + selected + (wrap ? tag : ' '); } if (removedFirstNewLine) { - insertText = '\n' + insertText; + textToInsert = '\n' + textToInsert; } if (removedLastNewLine) { - insertText += '\n'; + textToInsert += '\n'; } - if (document.queryCommandSupported('insertText')) { - inserted = document.execCommand('insertText', false, insertText); - } - if (!inserted) { - try { - document.execCommand("ms-beginUndoUnit"); - } catch (error) {} - textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); - try { - document.execCommand("ms-endUndoUnit"); - } catch (error) {} - } - return this.moveCursor(textArea, tag, wrap, removedLastNewLine); -}; + insertText(textArea, textToInsert); + return moveCursor(textArea, tag, wrap, removedLastNewLine); +} -textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { - var pos; - if (!textArea.setSelectionRange) { - return; - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (wrapped) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; - } - - if (removedLastNewLine) { - pos -= 1; - } - - return textArea.setSelectionRange(pos, pos); - } -}; - -textUtils.updateText = function(textArea, tag, blockTag, wrap) { +function updateText(textArea, tag, blockTag, wrap) { var $textArea, selected, text; $textArea = $(textArea); textArea = $textArea.get(0); text = $textArea.val(); - selected = this.selectedText(text, textArea); + selected = selectedText(text, textArea); $textArea.focus(); - return this.insertText(textArea, text, tag, blockTag, selected, wrap); -}; + return insertMarkdownText(textArea, text, tag, blockTag, selected, wrap); +} -textUtils.init = function(form) { - var self; - self = this; +function replaceRange(s, start, end, substitute) { + return s.substring(0, start) + substitute + s.substring(end); +} + +export function addMarkdownListeners(form) { return $('.js-md', form).off('click').on('click', function() { - var $this; - $this = $(this); - return self.updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend')); + const $this = $(this); + return updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend')); }); -}; +} -textUtils.removeListeners = function(form) { +export function removeMarkdownListeners(form) { return $('.js-md', form).off('click'); -}; - -textUtils.replaceRange = function(s, start, end, substitute) { - return s.substring(0, start) + substitute + s.substring(end); -}; - -export default textUtils; +} diff --git a/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml b/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml new file mode 100644 index 00000000000..0e892a51bc5 --- /dev/null +++ b/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml @@ -0,0 +1,5 @@ +--- +title: Fix Firefox stealing formatting characters on issue notes +merge_request: +author: +type: fixed diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js index a95a7e2a5be..ca0e7c395a0 100644 --- a/spec/javascripts/lib/utils/text_markdown_spec.js +++ b/spec/javascripts/lib/utils/text_markdown_spec.js @@ -1,4 +1,4 @@ -import textUtils from '~/lib/utils/text_markdown'; +import { insertMarkdownText } from '~/lib/utils/text_markdown'; describe('init markdown', () => { let textArea; @@ -21,7 +21,7 @@ describe('init markdown', () => { textArea.selectionStart = 0; textArea.selectionEnd = 0; - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -32,7 +32,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}\n* `); }); @@ -43,7 +43,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -54,7 +54,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); -- cgit v1.2.1 From 7224b8cea6d674c56393626c80f3cc8bdfa26efe Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Tue, 20 Mar 2018 17:26:30 +0000 Subject: Adds a bigger width to the performance bar modal box and breaks the content --- .../javascripts/performance_bar/components/detailed_metric.vue | 1 + app/assets/stylesheets/framework/common.scss | 5 +++++ app/assets/stylesheets/performance_bar.scss | 10 ++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 145465f4ee9..68d539c57a4 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -64,6 +64,7 @@ export default { {{ item[key] }} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 37d33320445..92143b625c6 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -446,6 +446,11 @@ img.emoji { opacity: .5; } +.break-all { + word-wrap: break-word; + word-break: break-all; +} + /** COMMON CLASSES **/ .prepend-top-0 { margin-top: 0; } .prepend-top-5 { margin-top: 5px; } diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index d06148a7bf8..9c1d36fc59a 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -108,8 +108,14 @@ } } - .performance-bar-modal .modal-footer { - display: none; + .performance-bar-modal { + .modal-footer { + display: none; + } + + .modal-dialog { + width: 860px; + } } } -- cgit v1.2.1 From da2191afa0e1bf4e0d1f605df9528800eec91c61 Mon Sep 17 00:00:00 2001 From: James Edwards-Jones Date: Tue, 20 Mar 2018 09:42:35 +0000 Subject: OmniauthInitializer created to improve devise.rb This should simplify refactoring and allow testing --- config/initializers/devise.rb | 46 +------------------- lib/gitlab/omniauth_initializer.rb | 65 ++++++++++++++++++++++++++++ spec/lib/gitlab/omniauth_initializer_spec.rb | 65 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 45 deletions(-) create mode 100644 lib/gitlab/omniauth_initializer.rb create mode 100644 spec/lib/gitlab/omniauth_initializer_spec.rb diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index f642e6d47e0..362b9cc9a88 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -219,49 +219,5 @@ Devise.setup do |config| end end - Gitlab.config.omniauth.providers.each do |provider| - provider_arguments = [] - - %w[app_id app_secret].each do |argument| - provider_arguments << provider[argument] if provider[argument] - end - - case provider['args'] - when Array - # An Array from the configuration will be expanded. - provider_arguments.concat provider['args'] - when Hash - # Add procs for handling SLO - if provider['name'] == 'cas3' - provider['args'][:on_single_sign_out] = lambda do |request| - ticket = request.params[:session_index] - raise "Service Ticket not found." unless Gitlab::Auth::OAuth::Session.valid?(:cas3, ticket) - - Gitlab::Auth::OAuth::Session.destroy(:cas3, ticket) - true - end - end - - if provider['name'] == 'authentiq' - provider['args'][:remote_sign_out_handler] = lambda do |request| - authentiq_session = request.params['sid'] - if Gitlab::Auth::OAuth::Session.valid?(:authentiq, authentiq_session) - Gitlab::Auth::OAuth::Session.destroy(:authentiq, authentiq_session) - true - else - false - end - end - end - - if provider['name'] == 'shibboleth' - provider['args'][:fail_with_empty_uid] = true - end - - # A Hash from the configuration will be passed as is. - provider_arguments << provider['args'].symbolize_keys - end - - config.omniauth provider['name'].to_sym, *provider_arguments - end + Gitlab::OmniauthInitializer.new(config).execute(Gitlab.config.omniauth.providers) end diff --git a/lib/gitlab/omniauth_initializer.rb b/lib/gitlab/omniauth_initializer.rb new file mode 100644 index 00000000000..a2c37444730 --- /dev/null +++ b/lib/gitlab/omniauth_initializer.rb @@ -0,0 +1,65 @@ +module Gitlab + class OmniauthInitializer + def initialize(devise_config) + @devise_config = devise_config + end + + def config + @devise_config + end + + def execute(providers) + initialize_providers(providers) + end + + private + + def initialize_providers(providers) + providers.each do |provider| + provider_arguments = [] + + %w[app_id app_secret].each do |argument| + provider_arguments << provider[argument] if provider[argument] + end + + case provider['args'] + when Array + # An Array from the configuration will be expanded. + provider_arguments.concat provider['args'] + when Hash + # Add procs for handling SLO + if provider['name'] == 'cas3' + provider['args'][:on_single_sign_out] = lambda do |request| + ticket = request.params[:session_index] + raise "Service Ticket not found." unless Gitlab::Auth::OAuth::Session.valid?(:cas3, ticket) + + Gitlab::Auth::OAuth::Session.destroy(:cas3, ticket) + true + end + end + + if provider['name'] == 'authentiq' + provider['args'][:remote_sign_out_handler] = lambda do |request| + authentiq_session = request.params['sid'] + if Gitlab::Auth::OAuth::Session.valid?(:authentiq, authentiq_session) + Gitlab::Auth::OAuth::Session.destroy(:authentiq, authentiq_session) + true + else + false + end + end + end + + if provider['name'] == 'shibboleth' + provider['args'][:fail_with_empty_uid] = true + end + + # A Hash from the configuration will be passed as is. + provider_arguments << provider['args'].symbolize_keys + end + + config.omniauth provider['name'].to_sym, *provider_arguments + end + end + end +end diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb new file mode 100644 index 00000000000..d808b4d49e0 --- /dev/null +++ b/spec/lib/gitlab/omniauth_initializer_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Gitlab::OmniauthInitializer do + let(:devise_config) { class_double(Devise) } + + subject { described_class.new(devise_config) } + + describe '#execute' do + it 'configures providers from array' do + generic_config = { 'name' => 'generic' } + + expect(devise_config).to receive(:omniauth).with(:generic) + + subject.execute([generic_config]) + end + + it 'allows "args" array for app_id and app_secret' do + legacy_config = { 'name' => 'legacy', 'args' => %w(123 abc) } + + expect(devise_config).to receive(:omniauth).with(:legacy, '123', 'abc') + + subject.execute([legacy_config]) + end + + it 'passes app_id and app_secret as additional arguments' do + twitter_config = { 'name' => 'twitter', 'app_id' => '123', 'app_secret' => 'abc' } + + expect(devise_config).to receive(:omniauth).with(:twitter, '123', 'abc') + + subject.execute([twitter_config]) + end + + it 'passes "args" hash as symbolized hash argument' do + hash_config = { 'name' => 'hash', 'args' => { 'custom' => 'format' } } + + expect(devise_config).to receive(:omniauth).with(:hash, custom: 'format') + + subject.execute([hash_config]) + end + + it 'configures fail_with_empty_uid for shibboleth' do + shibboleth_config = { 'name' => 'shibboleth', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:shibboleth, fail_with_empty_uid: true) + + subject.execute([shibboleth_config]) + end + + it 'configures remote_sign_out_handler proc for authentiq' do + authentiq_config = { 'name' => 'authentiq', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:authentiq, remote_sign_out_handler: an_instance_of(Proc)) + + subject.execute([authentiq_config]) + end + + it 'configures on_single_sign_out proc for cas3' do + cas3_config = { 'name' => 'cas3', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:cas3, on_single_sign_out: an_instance_of(Proc)) + + subject.execute([cas3_config]) + end + end +end -- cgit v1.2.1 From 97cf5d737d05f841232f962db98ac600299668b0 Mon Sep 17 00:00:00 2001 From: James Edwards-Jones Date: Tue, 20 Mar 2018 17:34:26 +0000 Subject: Omniauth callbacks moved to methods --- lib/gitlab/omniauth_initializer.rb | 64 ++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/lib/gitlab/omniauth_initializer.rb b/lib/gitlab/omniauth_initializer.rb index a2c37444730..1b8ffc8c096 100644 --- a/lib/gitlab/omniauth_initializer.rb +++ b/lib/gitlab/omniauth_initializer.rb @@ -27,32 +27,7 @@ module Gitlab # An Array from the configuration will be expanded. provider_arguments.concat provider['args'] when Hash - # Add procs for handling SLO - if provider['name'] == 'cas3' - provider['args'][:on_single_sign_out] = lambda do |request| - ticket = request.params[:session_index] - raise "Service Ticket not found." unless Gitlab::Auth::OAuth::Session.valid?(:cas3, ticket) - - Gitlab::Auth::OAuth::Session.destroy(:cas3, ticket) - true - end - end - - if provider['name'] == 'authentiq' - provider['args'][:remote_sign_out_handler] = lambda do |request| - authentiq_session = request.params['sid'] - if Gitlab::Auth::OAuth::Session.valid?(:authentiq, authentiq_session) - Gitlab::Auth::OAuth::Session.destroy(:authentiq, authentiq_session) - true - else - false - end - end - end - - if provider['name'] == 'shibboleth' - provider['args'][:fail_with_empty_uid] = true - end + set_provider_specific_defaults(provider) # A Hash from the configuration will be passed as is. provider_arguments << provider['args'].symbolize_keys @@ -61,5 +36,42 @@ module Gitlab config.omniauth provider['name'].to_sym, *provider_arguments end end + + def set_provider_specific_defaults(provider) + # Add procs for handling SLO + if provider['name'] == 'cas3' + provider['args'][:on_single_sign_out] = cas3_signout_handler + end + + if provider['name'] == 'authentiq' + provider['args'][:remote_sign_out_handler] = authentiq_signout_handler + end + + if provider['name'] == 'shibboleth' + provider['args'][:fail_with_empty_uid] = true + end + end + + def cas3_signout_handler + lambda do |request| + ticket = request.params[:session_index] + raise "Service Ticket not found." unless Gitlab::Auth::OAuth::Session.valid?(:cas3, ticket) + + Gitlab::Auth::OAuth::Session.destroy(:cas3, ticket) + true + end + end + + def authentiq_signout_handler + lambda do |request| + authentiq_session = request.params['sid'] + if Gitlab::Auth::OAuth::Session.valid?(:authentiq, authentiq_session) + Gitlab::Auth::OAuth::Session.destroy(:authentiq, authentiq_session) + true + else + false + end + end + end end end -- cgit v1.2.1 From 1362d9fe1383a2fa2cca563435064e622ec8e043 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 20 Mar 2018 14:38:43 +0100 Subject: Shortcut concurrent index creation/removal if no effect. Index creation does not have an effect if the index is present already. Index removal does not have an affect if the index is not present. This helps to avoid patterns like this in migrations: ``` if index_exists?(...) remove_concurrent_index(...) end ``` --- doc/development/migration_style_guide.md | 5 +- lib/gitlab/database/migration_helpers.rb | 15 ++++++ spec/lib/gitlab/database/migration_helpers_spec.rb | 62 ++++++++++++++++++---- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 243ac7f0c98..1e060ffd941 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -136,11 +136,14 @@ class MyMigration < ActiveRecord::Migration disable_ddl_transaction! def up - remove_concurrent_index :table_name, :column_name if index_exists?(:table_name, :column_name) + remove_concurrent_index :table_name, :column_name end end ``` +Note that it is not necessary to check if the index exists prior to +removing it. + ## Adding indexes If you need to add a unique index please keep in mind there is the possibility diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 21287a8efd0..55160ca8708 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -59,6 +59,11 @@ module Gitlab disable_statement_timeout end + if index_exists?(table_name, column_name, options) + Rails.logger.warn "Index not created because it already exists (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}" + return + end + add_index(table_name, column_name, options) end @@ -83,6 +88,11 @@ module Gitlab disable_statement_timeout end + unless index_exists?(table_name, column_name, options) + Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}" + return + end + remove_index(table_name, options.merge({ column: column_name })) end @@ -107,6 +117,11 @@ module Gitlab disable_statement_timeout end + unless index_exists_by_name?(table_name, index_name) + Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, index_name: #{index_name}" + return + end + remove_index(table_name, options.merge({ name: index_name })) end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 1de3a14b809..9074e17ae80 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -67,17 +67,35 @@ describe Gitlab::Database::MigrationHelpers do model.add_concurrent_index(:users, :foo, unique: true) end + + it 'does nothing if the index exists already' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(true) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :foo, unique: true) + end end context 'using MySQL' do - it 'creates a regular index' do - expect(Gitlab::Database).to receive(:postgresql?).and_return(false) + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + it 'creates a regular index' do expect(model).to receive(:add_index) .with(:users, :foo, {}) model.add_concurrent_index(:users, :foo) end + + it 'does nothing if the index exists already' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { unique: true }).and_return(true) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :foo, unique: true) + end end end @@ -95,6 +113,7 @@ describe Gitlab::Database::MigrationHelpers do context 'outside a transaction' do before do allow(model).to receive(:transaction_open?).and_return(false) + allow(model).to receive(:index_exists?).and_return(true) end context 'using PostgreSQL' do @@ -103,18 +122,41 @@ describe Gitlab::Database::MigrationHelpers do allow(model).to receive(:disable_statement_timeout) end - it 'removes the index concurrently by column name' do - expect(model).to receive(:remove_index) - .with(:users, { algorithm: :concurrently, column: :foo }) + describe 'by column name' do + it 'removes the index concurrently' do + expect(model).to receive(:remove_index) + .with(:users, { algorithm: :concurrently, column: :foo }) - model.remove_concurrent_index(:users, :foo) + model.remove_concurrent_index(:users, :foo) + end + + it 'does nothing if the index does not exist' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(false) + expect(model).not_to receive(:remove_index) + + model.remove_concurrent_index(:users, :foo, unique: true) + end end - it 'removes the index concurrently by index name' do - expect(model).to receive(:remove_index) - .with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + describe 'by index name' do + before do + allow(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(true) + end + + it 'removes the index concurrently by index name' do + expect(model).to receive(:remove_index) + .with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end + + it 'does nothing if the index does not exist' do + expect(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(false) + expect(model).not_to receive(:remove_index) - model.remove_concurrent_index_by_name(:users, "index_x_by_y") + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end end end -- cgit v1.2.1 From c914883a2b350bb53313df3eb97e6e0064d9f655 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Tue, 20 Mar 2018 15:50:07 +0100 Subject: Shortcut concurrent foreign key creation if already exists. Closes #43887. --- ..._project_foreign_keys_with_cascading_deletes.rb | 8 +--- ...703102400_add_stage_id_foreign_key_to_builds.rb | 12 +---- ...0713104829_add_foreign_key_to_merge_requests.rb | 12 +---- ...3124427_build_user_interacted_projects_table.rb | 10 ++-- lib/gitlab/database/migration_helpers.rb | 55 +++++++++++++++------- spec/lib/gitlab/database/migration_helpers_spec.rb | 46 +++++++++++++++++- 6 files changed, 92 insertions(+), 51 deletions(-) diff --git a/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb b/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb index af6d10b5158..1199073ed3a 100644 --- a/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb +++ b/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb @@ -154,7 +154,7 @@ class ProjectForeignKeysWithCascadingDeletes < ActiveRecord::Migration end def add_foreign_key_if_not_exists(source, target, column:) - return if foreign_key_exists?(source, column) + return if foreign_key_exists?(source, target, column: column) add_concurrent_foreign_key(source, target, column: column) end @@ -175,12 +175,6 @@ class ProjectForeignKeysWithCascadingDeletes < ActiveRecord::Migration rescue ArgumentError end - def foreign_key_exists?(table, column) - foreign_keys(table).any? do |key| - key.options[:column] == column.to_s - end - end - def connection # Rails memoizes connection objects, but this causes them to be shared # amongst threads; we don't want that. diff --git a/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb b/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb index 68b947583d3..a89d348b127 100644 --- a/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb +++ b/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb @@ -10,13 +10,13 @@ class AddStageIdForeignKeyToBuilds < ActiveRecord::Migration add_concurrent_index(:ci_builds, :stage_id) end - unless foreign_key_exists?(:ci_builds, :stage_id) + unless foreign_key_exists?(:ci_builds, :ci_stages, column: :stage_id) add_concurrent_foreign_key(:ci_builds, :ci_stages, column: :stage_id, on_delete: :cascade) end end def down - if foreign_key_exists?(:ci_builds, :stage_id) + if foreign_key_exists?(:ci_builds, column: :stage_id) remove_foreign_key(:ci_builds, column: :stage_id) end @@ -24,12 +24,4 @@ class AddStageIdForeignKeyToBuilds < ActiveRecord::Migration remove_concurrent_index(:ci_builds, :stage_id) end end - - private - - def foreign_key_exists?(table, column) - foreign_keys(:ci_builds).any? do |key| - key.options[:column] == column.to_s - end - end end diff --git a/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb b/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb index c25d4fd5986..c409915ceed 100644 --- a/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb +++ b/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb @@ -23,23 +23,15 @@ class AddForeignKeyToMergeRequests < ActiveRecord::Migration merge_requests.update_all(head_pipeline_id: nil) end - unless foreign_key_exists?(:merge_requests, :head_pipeline_id) + unless foreign_key_exists?(:merge_requests, column: :head_pipeline_id) add_concurrent_foreign_key(:merge_requests, :ci_pipelines, column: :head_pipeline_id, on_delete: :nullify) end end def down - if foreign_key_exists?(:merge_requests, :head_pipeline_id) + if foreign_key_exists?(:merge_requests, column: :head_pipeline_id) remove_foreign_key(:merge_requests, column: :head_pipeline_id) end end - - private - - def foreign_key_exists?(table, column) - foreign_keys(table).any? do |key| - key.options[:column] == column.to_s - end - end end diff --git a/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb index d1a29a5c71b..9addd36dca6 100644 --- a/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb +++ b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb @@ -26,11 +26,11 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration def down execute "TRUNCATE user_interacted_projects" - if foreign_key_exists?(:user_interacted_projects, :user_id) + if foreign_key_exists?(:user_interacted_projects, :users) remove_foreign_key :user_interacted_projects, :users end - if foreign_key_exists?(:user_interacted_projects, :project_id) + if foreign_key_exists?(:user_interacted_projects, :projects) remove_foreign_key :user_interacted_projects, :projects end @@ -115,7 +115,7 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration end def create_fk(table, target, column) - return if foreign_key_exists?(table, column) + return if foreign_key_exists?(table, target, column: column) add_foreign_key table, target, column: column, on_delete: :cascade end @@ -158,11 +158,11 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration add_concurrent_index :user_interacted_projects, [:project_id, :user_id], unique: true, name: UNIQUE_INDEX_NAME end - unless foreign_key_exists?(:user_interacted_projects, :user_id) + unless foreign_key_exists?(:user_interacted_projects, :users, column: :user_id) add_concurrent_foreign_key :user_interacted_projects, :users, column: :user_id, on_delete: :cascade end - unless foreign_key_exists?(:user_interacted_projects, :project_id) + unless foreign_key_exists?(:user_interacted_projects, :projects, column: :project_id) add_concurrent_foreign_key :user_interacted_projects, :projects, column: :project_id, on_delete: :cascade end end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 55160ca8708..44ca434056f 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -155,6 +155,13 @@ module Gitlab # of PostgreSQL's "VALIDATE CONSTRAINT". As a result we'll just fall # back to the normal foreign key procedure. if Database.mysql? + if foreign_key_exists?(source, target, column: column) + Rails.logger.warn "Foreign key not created because it exists already " \ + "(this may be due to an aborted migration or similar): " \ + "source: #{source}, target: #{target}, column: #{column}" + return + end + return add_foreign_key(source, target, column: column, on_delete: on_delete) @@ -166,25 +173,43 @@ module Gitlab key_name = concurrent_foreign_key_name(source, column) - # Using NOT VALID allows us to create a key without immediately - # validating it. This means we keep the ALTER TABLE lock only for a - # short period of time. The key _is_ enforced for any newly created - # data. - execute <<-EOF.strip_heredoc - ALTER TABLE #{source} - ADD CONSTRAINT #{key_name} - FOREIGN KEY (#{column}) - REFERENCES #{target} (id) - #{on_delete ? "ON DELETE #{on_delete.upcase}" : ''} - NOT VALID; - EOF + unless foreign_key_exists?(source, target, column: column) + Rails.logger.warn "Foreign key not created because it exists already " \ + "(this may be due to an aborted migration or similar): " \ + "source: #{source}, target: #{target}, column: #{column}" + + # Using NOT VALID allows us to create a key without immediately + # validating it. This means we keep the ALTER TABLE lock only for a + # short period of time. The key _is_ enforced for any newly created + # data. + execute <<-EOF.strip_heredoc + ALTER TABLE #{source} + ADD CONSTRAINT #{key_name} + FOREIGN KEY (#{column}) + REFERENCES #{target} (id) + #{on_delete ? "ON DELETE #{on_delete.upcase}" : ''} + NOT VALID; + EOF + end # Validate the existing constraint. This can potentially take a very # long time to complete, but fortunately does not lock the source table # while running. + # + # Note this is a no-op in case the constraint is VALID already execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{key_name};") end + def foreign_key_exists?(source, target = nil, column: nil) + foreign_keys(source).any? do |key| + if column + key.options[:column].to_s == column.to_s + else + key.to_table.to_s == target.to_s + end + end + end + # Returns the name for a concurrent foreign key. # # PostgreSQL constraint names have a limit of 63 bytes. The logic used @@ -875,12 +900,6 @@ into similar problems in the future (e.g. when new tables are created). end end - def foreign_key_exists?(table, column) - foreign_keys(table).any? do |key| - key.options[:column] == column.to_s - end - end - # Rails' index_exists? doesn't work when you only give it a table and index # name. As such we have to use some extra code to check if an index exists for # a given name. diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 9074e17ae80..a41b7f4e104 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -183,6 +183,10 @@ describe Gitlab::Database::MigrationHelpers do end describe '#add_concurrent_foreign_key' do + before do + allow(model).to receive(:foreign_key_exists?).and_return(false) + end + context 'inside a transaction' do it 'raises an error' do expect(model).to receive(:transaction_open?).and_return(true) @@ -199,14 +203,23 @@ describe Gitlab::Database::MigrationHelpers do end context 'using MySQL' do - it 'creates a regular foreign key' do + before do allow(Gitlab::Database).to receive(:mysql?).and_return(true) + end + it 'creates a regular foreign key' do expect(model).to receive(:add_foreign_key) .with(:projects, :users, column: :user_id, on_delete: :cascade) model.add_concurrent_foreign_key(:projects, :users, column: :user_id) end + + it 'does not create a foreign key if it exists already' do + expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true) + expect(model).not_to receive(:add_foreign_key) + + model.add_concurrent_foreign_key(:projects, :users, column: :user_id) + end end context 'using PostgreSQL' do @@ -231,6 +244,14 @@ describe Gitlab::Database::MigrationHelpers do column: :user_id, on_delete: :nullify) end + + it 'does not create a foreign key if it exists already' do + expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true) + expect(model).not_to receive(:execute).with(/ADD CONSTRAINT/) + expect(model).to receive(:execute).with(/VALIDATE CONSTRAINT/) + + model.add_concurrent_foreign_key(:projects, :users, column: :user_id) + end end end end @@ -245,6 +266,29 @@ describe Gitlab::Database::MigrationHelpers do end end + describe '#foreign_key_exists?' do + before do + key = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(:projects, :users, { column: :non_standard_id }) + allow(model).to receive(:foreign_keys).with(:projects).and_return([key]) + end + + it 'finds existing foreign keys by column' do + expect(model.foreign_key_exists?(:projects, :users, column: :non_standard_id)).to be_truthy + end + + it 'finds existing foreign keys by target table only' do + expect(model.foreign_key_exists?(:projects, :users)).to be_truthy + end + + it 'compares by column name if given' do + expect(model.foreign_key_exists?(:projects, :users, column: :user_id)).to be_falsey + end + + it 'compares by target if no column given' do + expect(model.foreign_key_exists?(:projects, :other_table)).to be_falsey + end + end + describe '#disable_statement_timeout' do context 'using PostgreSQL' do it 'disables statement timeouts' do -- cgit v1.2.1 From c12bd5e8aafdeb4add5f8fe2d268d0580c954374 Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Wed, 21 Mar 2018 09:15:42 +1100 Subject: [Rails5] Update ar5_batching initializer --- config/initializers/ar5_batching.rb | 72 ++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/config/initializers/ar5_batching.rb b/config/initializers/ar5_batching.rb index 6ebaf8834d2..874455ce5af 100644 --- a/config/initializers/ar5_batching.rb +++ b/config/initializers/ar5_batching.rb @@ -1,41 +1,39 @@ -# Port ActiveRecord::Relation#in_batches from ActiveRecord 5. -# https://github.com/rails/rails/blob/ac027338e4a165273607dccee49a3d38bc836794/activerecord/lib/active_record/relation/batches.rb#L184 -# TODO: this can be removed once we're using AR5. -raise "Vendored ActiveRecord 5 code! Delete #{__FILE__}!" if ActiveRecord::VERSION::MAJOR >= 5 - -module ActiveRecord - module Batches - # Differences from upstream: enumerator support was removed, and custom - # order/limit clauses are ignored without a warning. - def in_batches(of: 1000, start: nil, finish: nil, load: false) - raise "Must provide a block" unless block_given? - - relation = self.reorder(batch_order).limit(of) - relation = relation.where(arel_table[primary_key].gteq(start)) if start - relation = relation.where(arel_table[primary_key].lteq(finish)) if finish - batch_relation = relation - - loop do - if load - records = batch_relation.records - ids = records.map(&:id) - yielded_relation = self.where(primary_key => ids) - yielded_relation.load_records(records) - else - ids = batch_relation.pluck(primary_key) - yielded_relation = self.where(primary_key => ids) +# Remove this file when upgraded to rails 5.0. +unless Gitlab.rails5? + module ActiveRecord + module Batches + # Differences from upstream: enumerator support was removed, and custom + # order/limit clauses are ignored without a warning. + def in_batches(of: 1000, start: nil, finish: nil, load: false) + raise "Must provide a block" unless block_given? + + relation = self.reorder(batch_order).limit(of) + relation = relation.where(arel_table[primary_key].gteq(start)) if start + relation = relation.where(arel_table[primary_key].lteq(finish)) if finish + batch_relation = relation + + loop do + if load + records = batch_relation.records + ids = records.map(&:id) + yielded_relation = self.where(primary_key => ids) + yielded_relation.load_records(records) + else + ids = batch_relation.pluck(primary_key) + yielded_relation = self.where(primary_key => ids) + end + + break if ids.empty? + + primary_key_offset = ids.last + raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset + + yield yielded_relation + + break if ids.length < of + + batch_relation = relation.where(arel_table[primary_key].gt(primary_key_offset)) end - - break if ids.empty? - - primary_key_offset = ids.last - raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset - - yield yielded_relation - - break if ids.length < of - - batch_relation = relation.where(arel_table[primary_key].gt(primary_key_offset)) end end end -- cgit v1.2.1 From cf0034bb7cdd8bdbfc2bf42971d4cf49f0ac9ed0 Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Wed, 21 Mar 2018 09:21:33 +1100 Subject: [Rails5] Update ar5_pg_10_support initializer --- config/initializers/ar5_pg_10_support.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/initializers/ar5_pg_10_support.rb b/config/initializers/ar5_pg_10_support.rb index a529c74a8ce..40548290ce8 100644 --- a/config/initializers/ar5_pg_10_support.rb +++ b/config/initializers/ar5_pg_10_support.rb @@ -1,6 +1,5 @@ -raise "Vendored ActiveRecord 5 code! Delete #{__FILE__}!" if ActiveRecord::VERSION::MAJOR >= 5 - -if Gitlab::Database.postgresql? +# Remove this file when upgraded to rails 5.0. +if !Gitlab.rails5? && Gitlab::Database.postgresql? require 'active_record/connection_adapters/postgresql_adapter' require 'active_record/connection_adapters/postgresql/schema_statements' -- cgit v1.2.1 From 9eb30418c6dc3e7cfde438dc3b5a7213d5ab8292 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Fri, 16 Mar 2018 12:27:08 -0600 Subject: Added monitoring docs --- doc/development/new_fe_guide/development/performance.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/doc/development/new_fe_guide/development/performance.md b/doc/development/new_fe_guide/development/performance.md index 26b07874f0f..244dfb3756f 100644 --- a/doc/development/new_fe_guide/development/performance.md +++ b/doc/development/new_fe_guide/development/performance.md @@ -1,3 +1,16 @@ # Performance -> TODO: Add content +## Monitoring + +We have a performance dashboard available in one of our [grafana instances](https://performance.gprd.gitlab.com/dashboard/db/sitespeed-page-summary?orgId=1). This dashboard automatically aggregates metric data from [sitespeed.io](https://sitespeed.io) every 6 hours. These changes are displayed after a set number of pages are aggregated. + +These pages can be found inside a text file in the gitlab-build-images [repository](https://gitlab.com/gitlab-org/gitlab-build-images) called [gitlab.txt](https://gitlab.com/gitlab-org/gitlab-build-images/blob/master/scripts/gitlab.txt) +Any frontend engineer can contribute to this dashboard. They can contribute by adding or removing urls of pages from this text file. Please have a [frontend monitoring expert](https://about.gitlab.com/team) review your changes before assigning to a maintainer of the `gitlab-build-images` project. The changes will go live on the next scheduled run after the changes are merged into `master`. + +There are 3 recommended high impact metrics to review on each page + +* [First visual change](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint) +* [Speed Index](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index) +* [Visual Complete 95%](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index) + +For these metrics, lower numbers are better as it means that the website is more performant. -- cgit v1.2.1 From 467aa65e115a7d7350f41c4936833cf0e6837807 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 20 Mar 2018 16:29:24 -0700 Subject: Strip any query string parameters from Location headers from lograge Port of https://github.com/roidrage/lograge/pull/241 --- config/initializers/lograge.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index 114c1cb512f..49fdd23064c 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -1,3 +1,21 @@ +# Monkey patch lograge until https://github.com/roidrage/lograge/pull/241 is released +module Lograge + class RequestLogSubscriber < ActiveSupport::LogSubscriber + def strip_query_string(path) + index = path.index('?') + index ? path[0, index] : path + end + + def extract_location + location = Thread.current[:lograge_location] + return {} unless location + + Thread.current[:lograge_location] = nil + { location: strip_query_string(location) } + end + end +end + # Only use Lograge for Rails unless Sidekiq.server? filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log") -- cgit v1.2.1 From 8bde23742c710f70f22f22bc8e39ff36cfe25a6c Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 18 Mar 2018 00:30:11 +0900 Subject: Apply NestingDepth (level 5) (framework/dropdowns.scss) --- app/assets/stylesheets/framework/dropdowns.scss | 6 ++---- changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml | 5 +++++ 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 127583626cf..6397757bf88 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -501,10 +501,8 @@ -moz-osx-font-smoothing: grayscale; } - &.dropdown-menu-user-link { - &::before { - top: 50%; - } + &.dropdown-menu-user-link::before { + top: 50%; } } diff --git a/changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml b/changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml new file mode 100644 index 00000000000..30a8dc63983 --- /dev/null +++ b/changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml @@ -0,0 +1,5 @@ +--- +title: Apply NestingDepth (level 5) (framework/dropdowns.scss) +merge_request: 17820 +author: Takuya Noguchi +type: other -- cgit v1.2.1 From e3507c9282241047d4a983f6251c5692b0f9edee Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Wed, 28 Feb 2018 13:56:18 +1100 Subject: Add inverse_of: :pipeline for pipeline.builds (#37987) This appears to reduce the number of queries in pipeline creation of gitlab-ce's .gitlab-ci.yml by 92 This also means we now need to mock this ci yaml file properly in the test because otherwise the yaml_errors stay on the object and make all the pipelines failed. --- app/models/ci/pipeline.rb | 2 +- spec/services/ci/process_pipeline_service_spec.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a72a815bfe8..103c36c4668 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -14,7 +14,7 @@ module Ci has_many :stages has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :builds, foreign_key: :commit_id + has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index 0ce41e7c7ee..feb5120bc68 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -9,6 +9,8 @@ describe Ci::ProcessPipelineService, '#execute' do end before do + stub_ci_pipeline_to_return_yaml_file + stub_not_protect_default_branch project.add_developer(user) -- cgit v1.2.1 From 78de52513027122b233c0e7db4a9833093e78838 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 21 Mar 2018 12:27:05 +0200 Subject: Move TimeTrackingCollapsedState vue component --- .../components/time_tracking/collapsed_state.js | 96 ------------------- .../components/time_tracking/collapsed_state.vue | 102 +++++++++++++++++++++ .../components/time_tracking/time_tracker.vue | 4 +- .../refactor-move-time-tracking-vue-components.yml | 5 + 4 files changed, 109 insertions(+), 98 deletions(-) delete mode 100644 app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue create mode 100644 changelogs/unreleased/refactor-move-time-tracking-vue-components.yml diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js deleted file mode 100644 index a9fbc7f1a2f..00000000000 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js +++ /dev/null @@ -1,96 +0,0 @@ -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; -import { abbreviateTime } from '../../../lib/utils/pretty_time'; - -export default { - name: 'time-tracking-collapsed-state', - props: { - showComparisonState: { - type: Boolean, - required: true, - }, - showSpentOnlyState: { - type: Boolean, - required: true, - }, - showEstimateOnlyState: { - type: Boolean, - required: true, - }, - showNoTimeTrackingState: { - type: Boolean, - required: true, - }, - timeSpentHumanReadable: { - type: String, - required: false, - default: '', - }, - timeEstimateHumanReadable: { - type: String, - required: false, - default: '', - }, - }, - computed: { - timeSpent() { - return this.abbreviateTime(this.timeSpentHumanReadable); - }, - timeEstimate() { - return this.abbreviateTime(this.timeEstimateHumanReadable); - }, - divClass() { - if (this.showComparisonState) { - return 'compare'; - } else if (this.showEstimateOnlyState) { - return 'estimate-only'; - } else if (this.showSpentOnlyState) { - return 'spend-only'; - } else if (this.showNoTimeTrackingState) { - return 'no-tracking'; - } - - return ''; - }, - spanClass() { - if (this.showComparisonState) { - return ''; - } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { - return 'bold'; - } else if (this.showNoTimeTrackingState) { - return 'no-value'; - } - - return ''; - }, - text() { - if (this.showComparisonState) { - return `${this.timeSpent} / ${this.timeEstimate}`; - } else if (this.showEstimateOnlyState) { - return `-- / ${this.timeEstimate}`; - } else if (this.showSpentOnlyState) { - return `${this.timeSpent} / --`; - } else if (this.showNoTimeTrackingState) { - return 'None'; - } - - return ''; - }, - }, - methods: { - abbreviateTime(timeStr) { - return abbreviateTime(timeStr); - }, - }, - template: ` - - `, -}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue new file mode 100644 index 00000000000..3b86f1145d1 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 230736a56b8..28240468d2c 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,6 +1,6 @@ diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 2f360ea6f6c..3ed07a4a47d 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -37,7 +37,7 @@ export default { diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 7b5e333011e..0e3ac636661 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -1,11 +1,11 @@ - + diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue index c43e0a0490f..16f792d635a 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue @@ -2,14 +2,14 @@ import axios from '~/lib/utils/axios_utils'; import Flash from '~/flash'; - import modal from '~/vue_shared/components/modal.vue'; + import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { n__, s__, sprintf } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; export default { components: { - modal, + DeprecatedModal, }, props: { issueCount: { @@ -92,7 +92,7 @@ Once deleted, it cannot be undone or recovered.`), - + diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index c9028952ddd..714aed1333e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,5 +1,5 @@ - + diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue new file mode 100644 index 00000000000..dcf1489b37c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue @@ -0,0 +1,173 @@ + + + diff --git a/app/assets/javascripts/vue_shared/components/modal.vue b/app/assets/javascripts/vue_shared/components/modal.vue deleted file mode 100644 index 5f1364421aa..00000000000 --- a/app/assets/javascripts/vue_shared/components/modal.vue +++ /dev/null @@ -1,173 +0,0 @@ - - - diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index c35621c9ef3..21ffdc1dc86 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -1,11 +1,11 @@ diff --git a/changelogs/unreleased/winh-deprecate-old-modal.yml b/changelogs/unreleased/winh-deprecate-old-modal.yml new file mode 100644 index 00000000000..4fae1fafbea --- /dev/null +++ b/changelogs/unreleased/winh-deprecate-old-modal.yml @@ -0,0 +1,5 @@ +--- +title: Rename modal.vue to deprecated_modal.vue +merge_request: 17438 +author: +type: other diff --git a/spec/javascripts/vue_shared/components/deprecated_modal_spec.js b/spec/javascripts/vue_shared/components/deprecated_modal_spec.js new file mode 100644 index 00000000000..59d4e549a91 --- /dev/null +++ b/spec/javascripts/vue_shared/components/deprecated_modal_spec.js @@ -0,0 +1,67 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const modalComponent = Vue.extend(DeprecatedModal); + +describe('DeprecatedModal', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('props', () => { + describe('without primaryButtonLabel', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, { + primaryButtonLabel: null, + }); + }); + + it('does not render a primary button', () => { + expect(vm.$el.querySelector('.js-primary-button')).toBeNull(); + }); + }); + + describe('with id', () => { + describe('does not render a primary button', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, { + id: 'my-modal', + }); + }); + + it('assigns the id to the modal', () => { + expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull(); + }); + + it('does not show the modal immediately', () => { + expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show'); + }); + + it('does not show a backdrop', () => { + expect(vm.$el.querySelector('modal-backdrop')).toBeNull(); + }); + }); + }); + + it('works with data-toggle="modal"', (done) => { + setFixtures(` + + + `); + + const modalContainer = document.getElementById('modal-container'); + const modalButton = document.getElementById('modal-button'); + vm = mountComponent(modalComponent, { + id: 'my-modal', + }, modalContainer); + const modalElement = vm.$el.querySelector('#my-modal'); + $(modalElement).on('shown.bs.modal', () => done()); + + modalButton.click(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/modal_spec.js b/spec/javascripts/vue_shared/components/modal_spec.js deleted file mode 100644 index d01a94c25e5..00000000000 --- a/spec/javascripts/vue_shared/components/modal_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import modal from '~/vue_shared/components/modal.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; - -const modalComponent = Vue.extend(modal); - -describe('Modal', () => { - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('props', () => { - describe('without primaryButtonLabel', () => { - beforeEach(() => { - vm = mountComponent(modalComponent, { - primaryButtonLabel: null, - }); - }); - - it('does not render a primary button', () => { - expect(vm.$el.querySelector('.js-primary-button')).toBeNull(); - }); - }); - - describe('with id', () => { - describe('does not render a primary button', () => { - beforeEach(() => { - vm = mountComponent(modalComponent, { - id: 'my-modal', - }); - }); - - it('assigns the id to the modal', () => { - expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull(); - }); - - it('does not show the modal immediately', () => { - expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show'); - }); - - it('does not show a backdrop', () => { - expect(vm.$el.querySelector('modal-backdrop')).toBeNull(); - }); - }); - }); - - it('works with data-toggle="modal"', (done) => { - setFixtures(` - - - `); - - const modalContainer = document.getElementById('modal-container'); - const modalButton = document.getElementById('modal-button'); - vm = mountComponent(modalComponent, { - id: 'my-modal', - }, modalContainer); - const modalElement = vm.$el.querySelector('#my-modal'); - $(modalElement).on('shown.bs.modal', () => done()); - - modalButton.click(); - }); - }); -}); -- cgit v1.2.1 From 71f707bfe86f2f0a90182bc92033963aef32ce15 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Thu, 22 Mar 2018 10:55:02 +0100 Subject: Add the /help page in robots.txt The /help page has docs which we don't want to be crawled as we prefer the docs website instead. Related https://gitlab.com/gitlab-org/gitlab-ce/issues/44433 --- public/robots.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/public/robots.txt b/public/robots.txt index 123272a9834..1f9d42f4adc 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -20,6 +20,7 @@ Disallow: /projects/new Disallow: /groups/new Disallow: /groups/*/edit Disallow: /users +Disallow: /help # Global snippets User-Agent: * -- cgit v1.2.1 From 3886489a479956a1758b68105815418766155815 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Thu, 22 Mar 2018 10:55:35 +0000 Subject: Fix class name --- app/assets/javascripts/performance_bar/components/detailed_metric.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 882d6f7077a..db8a0055acd 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -70,7 +70,7 @@ export default { {{ item[key] }} -- cgit v1.2.1 From 05103f080cf0e40b8fe5e1774b8dd1f8084105e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Thu, 22 Mar 2018 12:08:16 +0100 Subject: Make Variable key not secret --- .../javascripts/ci_variable_list/ci_variable_list.js | 4 ++-- app/controllers/groups/variables_controller.rb | 2 +- .../projects/pipeline_schedules_controller.rb | 2 +- app/controllers/projects/variables_controller.rb | 2 +- app/models/ci/group_variable.rb | 1 - app/models/ci/pipeline_schedule_variable.rb | 1 - app/models/ci/variable.rb | 1 - app/views/ci/variables/_variable_row.html.haml | 2 +- .../projects/pipeline_schedules_controller_spec.rb | 18 +++++++++--------- spec/features/projects/pipeline_schedules_spec.rb | 4 ++-- .../ci_variable_list/native_form_variable_list_spec.js | 2 +- .../controllers/variables_shared_examples.rb | 6 +++--- 12 files changed, 21 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index c0bfe615478..e177a3bfdc7 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -29,7 +29,7 @@ export default class VariableList { selector: '.js-ci-variable-input-id', default: '', }, - secret_key: { + key: { selector: '.js-ci-variable-input-key', default: '', }, @@ -174,7 +174,7 @@ export default class VariableList { } toggleEnableRow(isEnabled = true) { - this.$container.find(this.inputMap.secret_key.selector).attr('disabled', !isEnabled); + this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); } diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 91e394c8ce8..6142e75b4c1 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -39,7 +39,7 @@ module Groups end def variable_params_attributes - %i[id secret_key secret_value protected _destroy] + %i[id key secret_value protected _destroy] end def authorize_admin_build! diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 6c087dfb71e..fa258f3d9af 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -92,7 +92,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def schedule_params params.require(:schedule) .permit(:description, :cron, :cron_timezone, :ref, :active, - variables_attributes: [:id, :secret_key, :secret_value, :_destroy] ) + variables_attributes: [:id, :key, :secret_value, :_destroy] ) end def authorize_play_pipeline_schedule! diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index ffe93522ca6..517d0b026c2 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -36,6 +36,6 @@ class Projects::VariablesController < Projects::ApplicationController end def variable_params_attributes - %i[id secret_key secret_value protected _destroy] + %i[id key secret_value protected _destroy] end end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 65399557289..62d768cc6cf 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -6,7 +6,6 @@ module Ci belongs_to :group - alias_attribute :secret_key, :key alias_attribute :secret_value, :value validates :key, uniqueness: { diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb index 2e30612a88e..03df4e3e638 100644 --- a/app/models/ci/pipeline_schedule_variable.rb +++ b/app/models/ci/pipeline_schedule_variable.rb @@ -5,7 +5,6 @@ module Ci belongs_to :pipeline_schedule - alias_attribute :secret_key, :key alias_attribute :secret_value, :value validates :key, uniqueness: { scope: :pipeline_schedule_id } diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index bcad55f115f..452cb910bca 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -6,7 +6,6 @@ module Ci belongs_to :project - alias_attribute :secret_key, :key alias_attribute :secret_value, :value validates :key, uniqueness: { diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index e72e48385da..5d4229c80af 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -9,7 +9,7 @@ - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" -- key_input_name = "#{form_field}[variables_attributes][][secret_key]" +- key_input_name = "#{form_field}[variables_attributes][][key]" - value_input_name = "#{form_field}[variables_attributes][][secret_value]" - protected_input_name = "#{form_field}[variables_attributes][][protected]" diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 11d0c41fe76..3506305f755 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -80,7 +80,7 @@ describe Projects::PipelineSchedulesController do context 'when variables_attributes has one variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] }) end @@ -101,8 +101,8 @@ describe Projects::PipelineSchedulesController do context 'when variables_attributes has two variables and duplicated' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }, - { secret_key: 'AAA', secret_value: 'BBB123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }, + { key: 'AAA', secret_value: 'BBB123' }] }) end @@ -153,7 +153,7 @@ describe Projects::PipelineSchedulesController do context 'when params include one variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] }) end @@ -170,8 +170,8 @@ describe Projects::PipelineSchedulesController do context 'when params include two duplicated variables' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }, - { secret_key: 'AAA', secret_value: 'BBB123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }, + { key: 'AAA', secret_value: 'BBB123' }] }) end @@ -196,7 +196,7 @@ describe Projects::PipelineSchedulesController do context 'when adds a new variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ secret_key: 'AAA', secret_value: 'AAA123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] }) end @@ -211,7 +211,7 @@ describe Projects::PipelineSchedulesController do context 'when adds a new duplicated variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ secret_key: 'CCC', secret_value: 'AAA123' }] + variables_attributes: [{ key: 'CCC', secret_value: 'AAA123' }] }) end @@ -254,7 +254,7 @@ describe Projects::PipelineSchedulesController do let(:schedule) do basic_param.merge({ variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true }, - { secret_key: 'CCC', secret_value: 'CCC123' }] + { key: 'CCC', secret_value: 'CCC123' }] }) end diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 0c9aa2d1497..065d00d51d4 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -159,9 +159,9 @@ feature 'Pipeline Schedules', :js do visit_pipelines_schedules click_link 'New schedule' fill_in_schedule_form - all('[name="schedule[variables_attributes][][secret_key]"]')[0].set('AAA') + all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') - all('[name="schedule[variables_attributes][][secret_key]"]')[1].set('BBB') + all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') save_pipeline_schedule end diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js index d3bcbdd92c1..94a0c999d66 100644 --- a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js @@ -19,7 +19,7 @@ describe('NativeFormVariableList', () => { describe('onFormSubmit', () => { it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { const $row = $wrapper.find('.js-row'); - expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][secret_key]'); + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][key]'); expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][secret_value]'); $wrapper.closest('form').trigger('trigger-submit'); diff --git a/spec/support/shared_examples/controllers/variables_shared_examples.rb b/spec/support/shared_examples/controllers/variables_shared_examples.rb index 7c7e345f715..b615a8f54cf 100644 --- a/spec/support/shared_examples/controllers/variables_shared_examples.rb +++ b/spec/support/shared_examples/controllers/variables_shared_examples.rb @@ -15,12 +15,12 @@ end shared_examples 'PATCH #update updates variables' do let(:variable_attributes) do { id: variable.id, - secret_key: variable.key, + key: variable.key, secret_value: variable.value, protected: variable.protected?.to_s } end let(:new_variable_attributes) do - { secret_key: 'new_key', + { key: 'new_key', secret_value: 'dummy_value', protected: 'false' } end @@ -29,7 +29,7 @@ shared_examples 'PATCH #update updates variables' do let(:variables_attributes) do [ variable_attributes.merge(secret_value: 'other_value'), - new_variable_attributes.merge(secret_key: '...?') + new_variable_attributes.merge(key: '...?') ] end -- cgit v1.2.1 From 78cee2c57b49d396c5998becfeb5032de196c939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Thu, 22 Mar 2018 12:57:56 +0100 Subject: Refactor AutoDevops pipeline logic into method --- .../projects/pipelines_settings_controller.rb | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 6ccefb7e562..557671ab186 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -10,14 +10,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController if service.execute flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." - if service.run_auto_devops_pipeline? - if @project.empty_repo? - flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." - else - CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) - flash[:success] = "A new Auto DevOps pipeline has been created, go to Pipelines page for details".html_safe - end - end + run_autodevops_pipeline(service) redirect_to project_settings_ci_cd_path(@project) else @@ -28,6 +21,18 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController private + def run_autodevops_pipeline(service) + return unless service.run_auto_devops_pipeline? + + if @project.empty_repo? + flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." + return + end + + CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) + flash[:success] = "A new Auto DevOps pipeline has been created, go to Pipelines page for details".html_safe + end + def update_params params.require(:project).permit( :runners_token, :builds_enabled, :build_allow_git_fetch, -- cgit v1.2.1 From fade3e24514a0e3d86a65f71c0f2d282c6b3f328 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Thu, 22 Mar 2018 13:16:47 +0100 Subject: Add image for clearing the cache docs Closes https://gitlab.com/gitlab-org/gitlab-ee/issues/4642 --- doc/ci/caching/img/clear_runners_cache.png | Bin 0 -> 16029 bytes doc/ci/caching/index.md | 18 ++++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 doc/ci/caching/img/clear_runners_cache.png diff --git a/doc/ci/caching/img/clear_runners_cache.png b/doc/ci/caching/img/clear_runners_cache.png new file mode 100644 index 00000000000..e5db4a47b3e Binary files /dev/null and b/doc/ci/caching/img/clear_runners_cache.png differ diff --git a/doc/ci/caching/index.md b/doc/ci/caching/index.md index c4b2a25d4a8..c159198d16b 100644 --- a/doc/ci/caching/index.md +++ b/doc/ci/caching/index.md @@ -454,17 +454,19 @@ next run of the pipeline, the cache will be stored in a different location. > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41249) in GitLab 10.4. If you want to avoid editing `.gitlab-ci.yml`, you can easily clear the cache -via GitLab's UI. This will have an impact on all caches of your project as -name of the cache directory will be renamed by appending an integer to it -(`-1`, `-2`, etc.): +via GitLab's UI: -1. Navigate to your project's **CI/CD > Pipelines** page. -1. Click on the **Clear Runner caches** to clean up the cache. -1. On the next push, your CI/CD job will use a new cache. +1. Navigate to your project's **CI/CD > Pipelines** page +1. Click on the **Clear Runner caches** button to clean up the cache + + ![Clear Runners cache](img/clear_runners_cache.png) + +1. On the next push, your CI/CD job will use a new cache Behind the scenes, this works by increasing a counter in the database, and the -value of that counter is used to create the key for the cache. After a push, a -new key is generated and the old cache is not valid anymore. +value of that counter is used to create the key for the cache by appending an +integer to it: `-1`, `-2`, etc. After a push, a new key is generated and the +old cache is not valid anymore. ## Cache vs artifacts -- cgit v1.2.1 From 92d51da23dfa3e83fb87fd381553a1fd93d49614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= Date: Thu, 22 Mar 2018 13:33:24 +0100 Subject: Use actual repo instead of stubbing method --- spec/controllers/projects/pipelines_settings_controller_spec.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/spec/controllers/projects/pipelines_settings_controller_spec.rb b/spec/controllers/projects/pipelines_settings_controller_spec.rb index 0dd8575e2b7..913b9bd804a 100644 --- a/spec/controllers/projects/pipelines_settings_controller_spec.rb +++ b/spec/controllers/projects/pipelines_settings_controller_spec.rb @@ -48,10 +48,6 @@ describe Projects::PipelinesSettingsController do end context 'when the project repository is empty' do - before do - allow_any_instance_of(Project).to receive(:empty_repo?).and_return(true) - end - it 'sets a warning flash' do expect(subject).to set_flash[:warning] end @@ -64,9 +60,7 @@ describe Projects::PipelinesSettingsController do end context 'when the project repository is not empty' do - before do - allow_any_instance_of(Project).to receive(:empty_repo?).and_return(false) - end + let(:project) { create(:project, :repository) } it 'sets a success flash' do allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) -- cgit v1.2.1 From 7ab6806f2f051705fcedd636feab2a603a03711e Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 18 Mar 2018 21:34:35 +0900 Subject: UX re-design branch items with flexbox --- app/assets/stylesheets/pages/branches.scss | 21 ++- app/views/projects/branches/_branch.html.haml | 141 +++++++++++---------- .../44386-better-ux-for-long-name-branches.yml | 5 + 3 files changed, 95 insertions(+), 72 deletions(-) create mode 100644 changelogs/unreleased/44386-better-ux-for-long-name-branches.yml diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss index 3e2fa8ca88d..49fe50977f5 100644 --- a/app/assets/stylesheets/pages/branches.scss +++ b/app/assets/stylesheets/pages/branches.scss @@ -1,6 +1,17 @@ +.content-list > .branch-item, +.branch-title { + display: flex; + align-items: center; +} + +.branch-info { + flex: auto; + min-width: 0; + overflow: hidden; +} + .divergence-graph { - padding: 12px 12px 0 0; - float: right; + padding: 0 6px; .graph-side { position: relative; @@ -53,3 +64,9 @@ background-color: $divergence-graph-separator-bg; } } + +.divergence-graph, +.branch-item .controls { + flex: 0 0 auto; + white-space: nowrap; +} diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 1da0e865a41..883dfb3e6c8 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -5,81 +5,82 @@ - number_commits_behind = diverging_commit_counts[:behind] - number_commits_ahead = diverging_commit_counts[:ahead] - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) -%li{ class: "js-branch-#{branch.name}" } - %div - = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated ref-name' do - = sprite_icon('fork', size: 12) - = branch.name -   - - if branch.name == @repository.root_ref - %span.label.label-primary default - - elsif merged - %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } - = s_('Branches|merged') +%li{ class: "branch-item js-branch-#{branch.name}" } + .branch-info + .branch-title + = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name' do + = sprite_icon('fork', size: 12) + = branch.name +   + - if branch.name == @repository.root_ref + %span.label.label-primary default + - elsif merged + %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } + = s_('Branches|merged') - - if protected_branch?(@project, branch) - %span.label.label-success - = s_('Branches|protected') - .controls.hidden-xs< - - if merge_project && create_mr_button?(@repository.root_ref, branch.name) - = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do - = _('Merge request') + - if protected_branch?(@project, branch) + %span.label.label-success + = s_('Branches|protected') - - if branch.name != @repository.root_ref - = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), - class: "btn btn-default #{'prepend-left-10' unless merge_project}", - method: :post, - title: s_('Branches|Compare') do - = s_('Branches|Compare') + .block-truncated + - if commit + = render 'projects/branches/commit', commit: commit, project: @project + - else + = s_('Branches|Cant find HEAD commit for this branch') - = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name] + - if branch.name != @repository.root_ref + .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), + default_branch: @repository.root_ref, + number_commits_ahead: diverging_count_label(number_commits_ahead) } } + .graph-side + .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } + %span.count.count-behind= diverging_count_label(number_commits_behind) + .graph-separator + .graph-side + .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" } + %span.count.count-ahead= diverging_count_label(number_commits_ahead) - - if can?(current_user, :push_code, @project) - - if branch.name == @project.repository.root_ref - %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", - disabled: true, - title: s_('Branches|The default branch cannot be deleted') } - = icon("trash-o") - - elsif protected_branch?(@project, branch) - - if can?(current_user, :delete_protected_branch, @project) - %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", - title: s_('Branches|Delete protected branch'), - data: { toggle: "modal", - target: "#modal-delete-branch", - delete_path: project_branch_path(@project, branch.name), - branch_name: branch.name, - is_merged: ("true" if merged) } } - = icon("trash-o") - - else - %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", - disabled: true, - title: s_('Branches|Only a project master or owner can delete a protected branch') } - = icon("trash-o") - - else - = link_to project_branch_path(@project, branch.name), - class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", - title: s_('Branches|Delete branch'), - method: :delete, - data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } }, - remote: true, - 'aria-label' => s_('Branches|Delete branch') do - = icon("trash-o") + .controls.hidden-xs< + - if merge_project && create_mr_button?(@repository.root_ref, branch.name) + = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do + = _('Merge request') - if branch.name != @repository.root_ref - .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), - default_branch: @repository.root_ref, - number_commits_ahead: diverging_count_label(number_commits_ahead) } } - .graph-side - .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } - %span.count.count-behind= diverging_count_label(number_commits_behind) - .graph-separator - .graph-side - .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" } - %span.count.count-ahead= diverging_count_label(number_commits_ahead) + = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), + class: "btn btn-default #{'prepend-left-10' unless merge_project}", + method: :post, + title: s_('Branches|Compare') do + = s_('Branches|Compare') + = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name] - - if commit - = render 'projects/branches/commit', commit: commit, project: @project - - else - %p - = s_('Branches|Cant find HEAD commit for this branch') + - if can?(current_user, :push_code, @project) + - if branch.name == @project.repository.root_ref + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", + disabled: true, + title: s_('Branches|The default branch cannot be deleted') } + = icon("trash-o") + - elsif protected_branch?(@project, branch) + - if can?(current_user, :delete_protected_branch, @project) + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + title: s_('Branches|Delete protected branch'), + data: { toggle: "modal", + target: "#modal-delete-branch", + delete_path: project_branch_path(@project, branch.name), + branch_name: branch.name, + is_merged: ("true" if merged) } } + = icon("trash-o") + - else + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", + disabled: true, + title: s_('Branches|Only a project master or owner can delete a protected branch') } + = icon("trash-o") + - else + = link_to project_branch_path(@project, branch.name), + class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + title: s_('Branches|Delete branch'), + method: :delete, + data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } }, + remote: true, + 'aria-label' => s_('Branches|Delete branch') do + = icon("trash-o") diff --git a/changelogs/unreleased/44386-better-ux-for-long-name-branches.yml b/changelogs/unreleased/44386-better-ux-for-long-name-branches.yml new file mode 100644 index 00000000000..16712486f0f --- /dev/null +++ b/changelogs/unreleased/44386-better-ux-for-long-name-branches.yml @@ -0,0 +1,5 @@ +--- +title: UX re-design branch items with flexbox +merge_request: 17832 +author: Takuya Noguchi +type: fixed -- cgit v1.2.1 From 64572b53496a6190ec8ba8375e82ea1ea89d5809 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Thu, 22 Mar 2018 11:23:17 +0100 Subject: Cleanup test for disabling comment submit button --- app/assets/javascripts/notes.js | 6 +- spec/javascripts/notes_spec.js | 317 ++++++++++++++++++++++++++++------------ 2 files changed, 228 insertions(+), 95 deletions(-) diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 09f0ea37103..b0573510ff9 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1809,9 +1809,11 @@ export default class Notes { } } + $closeBtn.text($closeBtn.data('originalText')); + /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios + return axios .post(`${formAction}?html=true`, formData) .then(res => { const note = res.data; @@ -1928,8 +1930,6 @@ export default class Notes { this.reenableTargetFormSubmitButton(e); this.addNoteError($form); }); - - return $closeBtn.text($closeBtn.data('originalText')); } /** diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 1858d6b6474..ec56ab0e2f0 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -16,15 +16,15 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; window.gl = window.gl || {}; gl.utils = gl.utils || {}; - const htmlEscape = (comment) => { - const escapedString = comment.replace(/["&'<>]/g, (a) => { + const htmlEscape = comment => { + const escapedString = comment.replace(/["&'<>]/g, a => { const escapedToken = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', - '`': '`' + '`': '`', }[a]; return escapedToken; @@ -39,7 +39,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; preloadFixtures(commentsTemplate); - beforeEach(function () { + beforeEach(function() { loadFixtures(commentsTemplate); gl.utils.disableButtonIfEmptyField = _.noop; window.project_uploads_path = 'http://test.host/uploads'; @@ -51,6 +51,17 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $('body').removeAttr('data-page'); }); + describe('addBinding', () => { + it('calls postComment when comment button is clicked', () => { + spyOn(Notes.prototype, 'postComment'); + this.notes = new Notes('', []); + + $('.js-comment-button').click(); + + expect(Notes.prototype.postComment).toHaveBeenCalled(); + }); + }); + describe('task lists', function() { let mock; @@ -58,7 +69,13 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; spyOn(axios, 'patch').and.callThrough(); mock = new MockAdapter(axios); - mock.onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`).reply(200, {}); + mock + .onPatch( + `${ + gl.TEST_HOST + }/frontend-fixtures/merge-requests-project/merge_requests/1.json`, + ) + .reply(200, {}); $('.js-comment-button').on('click', function(e) { e.preventDefault(); @@ -73,18 +90,27 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('modifies the Markdown field', function() { const changeEvent = document.createEvent('HTMLEvents'); changeEvent.initEvent('change', true, true); - $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent); + $('input[type=checkbox]') + .attr('checked', true)[1] + .dispatchEvent(changeEvent); - expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item'); + expect($('.js-task-list-field.original-task-list').val()).toBe( + '- [x] Task List Item', + ); }); it('submits an ajax request on tasklist:changed', function(done) { $('.js-task-list-container').trigger('tasklist:changed'); setTimeout(() => { - expect(axios.patch).toHaveBeenCalledWith(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, { - note: { note: '' }, - }); + expect(axios.patch).toHaveBeenCalledWith( + `${ + gl.TEST_HOST + }/frontend-fixtures/merge-requests-project/merge_requests/1.json`, + { + note: { note: '' }, + }, + ); done(); }); }); @@ -100,10 +126,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; spyOn(this.notes, 'renderNote').and.stub(); $(textarea).data('autosave', { - reset: function() {} + reset: function() {}, }); - $('.js-comment-button').on('click', (e) => { + $('.js-comment-button').on('click', e => { const $form = $(this); e.preventDefault(); this.notes.addNote($form); @@ -149,7 +175,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
${sampleComment}
`, note: sampleComment, - valid: true + valid: true, }; $form = $('form.js-main-target-form'); $notesContainer = $('ul.main-notes-list'); @@ -163,7 +189,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mock.restore(); }); - it('updates note and resets edit form', (done) => { + it('updates note and resets edit form', done => { spyOn(this.notes, 'revertNoteEditForm'); spyOn(this.notes, 'setupNewNote'); @@ -175,7 +201,9 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; updatedNote.note = 'bar'; this.notes.updateNote(updatedNote, $targetNote); - expect(this.notes.revertNoteEditForm).toHaveBeenCalledWith($targetNote); + expect(this.notes.revertNoteEditForm).toHaveBeenCalledWith( + $targetNote, + ); expect(this.notes.setupNewNote).toHaveBeenCalled(); done(); @@ -231,17 +259,14 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; note: 'heya', html: '
heya
', }; - $notesList = jasmine.createSpyObj('$notesList', [ - 'find', - 'append', - ]); + $notesList = jasmine.createSpyObj('$notesList', ['find', 'append']); notes = jasmine.createSpyObj('notes', [ 'setupNewNote', 'refresh', 'collapseLongCommitList', 'updateNotesCount', - 'putConflictEditWarningInPlace' + 'putConflictEditWarningInPlace', ]); notes.taskList = jasmine.createSpyObj('tasklist', ['init']); notes.note_ids = []; @@ -258,7 +283,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; Notes.isNewNote.and.returnValue(true); Notes.prototype.renderNote.call(notes, note, null, $notesList); - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + expect(Notes.animateAppendNote).toHaveBeenCalledWith( + note.html, + $notesList, + ); }); }); @@ -273,7 +301,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; Notes.prototype.renderNote.call(notes, note, null, $notesList); - expect(Notes.animateUpdateNote).toHaveBeenCalledWith(note.html, $note); + expect(Notes.animateUpdateNote).toHaveBeenCalledWith( + note.html, + $note, + ); expect(notes.setupNewNote).toHaveBeenCalledWith($newNote); }); @@ -301,7 +332,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $notesList.find.and.returnValue($note); Notes.prototype.renderNote.call(notes, note, null, $notesList); - expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(note, $note); + expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith( + note, + $note, + ); }); }); }); @@ -311,11 +345,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should consider same note text as the same', () => { const result = Notes.isUpdatedNote( { - note: 'initial' + note: 'initial', }, $(`
initial
-
`) + `), ); expect(result).toEqual(false); @@ -324,11 +358,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should consider same note with trailing newline as the same', () => { const result = Notes.isUpdatedNote( { - note: 'initial\n' + note: 'initial\n', }, $(`
initial\n
-
`) + `), ); expect(result).toEqual(false); @@ -337,11 +371,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should consider different notes as different', () => { const result = Notes.isUpdatedNote( { - note: 'foo' + note: 'foo', }, $(`
bar
-
`) + `), ); expect(result).toEqual(true); @@ -397,7 +431,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should call Notes.animateAppendNote', () => { Notes.prototype.renderDiscussionNote.call(notes, note, $form); - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list')); + expect(Notes.animateAppendNote).toHaveBeenCalledWith( + note.discussion_html, + $('.main-notes-list'), + ); }); it('should append to row selected with line_code', () => { @@ -428,7 +465,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('should call Notes.animateAppendNote', () => { - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer); + expect(Notes.animateAppendNote).toHaveBeenCalledWith( + note.html, + discussionContainer, + ); }); }); }); @@ -461,9 +501,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; beforeEach(() => { noteHTML = '
'; - $note = jasmine.createSpyObj('$note', [ - 'replaceWith' - ]); + $note = jasmine.createSpyObj('$note', ['replaceWith']); $updatedNote = Notes.animateUpdateNote(noteHTML, $note); }); @@ -501,7 +539,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
${sampleComment}
`, note: sampleComment, - valid: true + valid: true, }; let $form; let $notesContainer; @@ -534,10 +572,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mockNotesPost(); $('.js-comment-button').click(); - expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); + expect($notesContainer.find('.note.being-posted').length > 0).toEqual( + true, + ); }); - it('should remove placeholder note when new comment is done posting', (done) => { + it('should remove placeholder note when new comment is done posting', done => { mockNotesPost(); $('.js-comment-button').click(); @@ -549,33 +589,44 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); - it('should disable the submit button when comment button is clicked', (done) => { - expect($form.find('.js-comment-submit-button').is(':disabled')).toEqual(false); - - mockNotesPost(); - $('.js-comment-button').click(); - expect($form.find('.js-comment-submit-button').is(':disabled')).toEqual(true); - - setTimeout(() => { - expect($form.find('.js-comment-submit-button').is(':disabled')).toEqual(false); + describe('postComment', () => { + it('disables the submit button', done => { + const $submitButton = $form.find('.js-comment-submit-button'); + expect($submitButton).not.toBeDisabled(); + const dummyEvent = { + preventDefault() {}, + target: $submitButton, + }; + mock.onPost(NOTES_POST_PATH).replyOnce(() => { + expect($submitButton).toBeDisabled(); + return [200, note]; + }); - done(); + this.notes + .postComment(dummyEvent) + .then(() => { + expect($submitButton).not.toBeDisabled(); + }) + .then(done) + .catch(done.fail); }); }); - it('should show actual note element when new comment is done posting', (done) => { + it('should show actual note element when new comment is done posting', done => { mockNotesPost(); $('.js-comment-button').click(); setTimeout(() => { - expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true); + expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual( + true, + ); done(); }); }); - it('should reset Form when new comment is done posting', (done) => { + it('should reset Form when new comment is done posting', done => { mockNotesPost(); $('.js-comment-button').click(); @@ -587,19 +638,24 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); - it('should show flash error message when new comment failed to be posted', (done) => { + it('should show flash error message when new comment failed to be posted', done => { mockNotesPostError(); $('.js-comment-button').click(); setTimeout(() => { - expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + expect( + $notesContainer + .parent() + .find('.flash-container .flash-text') + .is(':visible'), + ).toEqual(true); done(); }); }); - it('should show flash error message when comment failed to be updated', (done) => { + it('should show flash error message when comment failed to be updated', done => { mockNotesPost(); $('.js-comment-button').click(); @@ -620,7 +676,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; .then(() => { const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect( + $updatedNoteEl + .find('.note-text') + .text() + .trim(), + ).toEqual(sampleComment); // See if comment reverted back to original expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown done(); @@ -634,12 +695,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; const note = { commands_changes: { assignee_id: 1, - emoji_award: '100' + emoji_award: '100', }, errors: { - commands_only: ['Commands applied'] + commands_only: ['Commands applied'], }, - valid: false + valid: false, }; let $form; let $notesContainer; @@ -654,12 +715,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; window.gon.current_user_fullname = 'Administrator'; gl.awardsHandler = { addAwardToEmojiBar: () => {}, - scrollToAwards: () => {} + scrollToAwards: () => {}, }; gl.GfmAutoComplete = { dataSources: { - commands: '/root/test-project/autocomplete_sources/commands' - } + commands: '/root/test-project/autocomplete_sources/commands', + }, }; $form = $('form.js-main-target-form'); $notesContainer = $('ul.main-notes-list'); @@ -670,14 +731,18 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mock.restore(); }); - it('should remove slash command placeholder when comment with slash commands is done posting', (done) => { + it('should remove slash command placeholder when comment with slash commands is done posting', done => { spyOn(gl.awardsHandler, 'addAwardToEmojiBar').and.callThrough(); $('.js-comment-button').click(); - expect($notesContainer.find('.system-note.being-posted').length).toEqual(1); // Placeholder shown + expect( + $notesContainer.find('.system-note.being-posted').length, + ).toEqual(1); // Placeholder shown setTimeout(() => { - expect($notesContainer.find('.system-note.being-posted').length).toEqual(0); // Placeholder removed + expect( + $notesContainer.find('.system-note.being-posted').length, + ).toEqual(0); // Placeholder removed done(); }); }); @@ -692,7 +757,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
${sampleComment}
`, note: sampleComment, - valid: true + valid: true, }; let $form; let $notesContainer; @@ -714,7 +779,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mock.restore(); }); - it('should not render a script tag', (done) => { + it('should not render a script tag', done => { $('.js-comment-button').click(); setTimeout(() => { @@ -723,8 +788,15 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $noteEl.find('textarea.js-note-text').html(updatedComment); $noteEl.find('.js-comment-save-button').click(); - const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`).find('.js-task-list-container'); - expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(''); + const $updatedNoteEl = $notesContainer + .find(`#note_${note.id}`) + .find('.js-task-list-container'); + expect( + $updatedNoteEl + .find('.note-text') + .text() + .trim(), + ).toEqual(''); done(); }); @@ -744,7 +816,9 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should return form metadata object from form reference', () => { $form.find('textarea.js-note-text').val(sampleComment); - const { formData, formContent, formAction } = this.notes.getFormData($form); + const { formData, formContent, formAction } = this.notes.getFormData( + $form, + ); expect(formData.indexOf(sampleComment) > -1).toBe(true); expect(formContent).toEqual(sampleComment); @@ -760,7 +834,9 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; const { formContent } = this.notes.getFormData($form); expect(_.escape).toHaveBeenCalledWith(sampleComment); - expect(formContent).toEqual('<script>alert("Boom!");</script>'); + expect(formContent).toEqual( + '<script>alert("Boom!");</script>', + ); }); }); @@ -770,7 +846,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('should return true when comment begins with a quick action', () => { - const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; + const sampleComment = + '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; const hasQuickActions = this.notes.hasQuickActions(sampleComment); expect(hasQuickActions).toBeTruthy(); @@ -794,7 +871,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; describe('stripQuickActions', () => { it('should strip quick actions from the comment which begins with a quick action', () => { this.notes = new Notes(); - const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; + const sampleComment = + '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe(''); @@ -802,7 +880,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should strip quick actions from the comment but leaves plain comment if it is present', () => { this.notes = new Notes(); - const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; + const sampleComment = + '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe('Merging this'); @@ -810,7 +889,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should NOT strip string that has slashes within', () => { this.notes = new Notes(); - const sampleComment = 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; + const sampleComment = + 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe(sampleComment); @@ -821,7 +901,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; const availableQuickActions = [ { name: 'close', description: 'Close this issue', params: [] }, { name: 'title', description: 'Change title', params: [{}] }, - { name: 'estimate', description: 'Set time estimate', params: [{}] } + { name: 'estimate', description: 'Set time estimate', params: [{}] }, ]; beforeEach(() => { @@ -830,17 +910,29 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should return executing quick action description when note has single quick action', () => { const sampleComment = '/close'; - expect(this.notes.getQuickActionDescription(sampleComment, availableQuickActions)).toBe('Applying command to close this issue'); + expect( + this.notes.getQuickActionDescription( + sampleComment, + availableQuickActions, + ), + ).toBe('Applying command to close this issue'); }); it('should return generic multiple quick action description when note has multiple quick actions', () => { const sampleComment = '/close\n/title [Duplicate] Issue foobar'; - expect(this.notes.getQuickActionDescription(sampleComment, availableQuickActions)).toBe('Applying multiple commands'); + expect( + this.notes.getQuickActionDescription( + sampleComment, + availableQuickActions, + ), + ).toBe('Applying multiple commands'); }); it('should return generic quick action description when available quick actions list is not populated', () => { const sampleComment = '/close\n/title [Duplicate] Issue foobar'; - expect(this.notes.getQuickActionDescription(sampleComment)).toBe('Applying command'); + expect(this.notes.getQuickActionDescription(sampleComment)).toBe( + 'Applying command', + ); }); }); @@ -870,14 +962,35 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; expect($tempNote.attr('id')).toEqual(uniqueId); expect($tempNote.hasClass('being-posted')).toBeTruthy(); expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); - $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { - expect($(this).attr('href')).toEqual(`/${currentUsername}`); - }); - expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar); - expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); - expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); - expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); - expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment); + $tempNote + .find('.timeline-icon > a, .note-header-info > a') + .each(function() { + expect($(this).attr('href')).toEqual(`/${currentUsername}`); + }); + expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual( + currentUserAvatar, + ); + expect( + $tempNote.find('.timeline-content').hasClass('discussion'), + ).toBeFalsy(); + expect( + $tempNoteHeader + .find('.hidden-xs') + .text() + .trim(), + ).toEqual(currentUserFullname); + expect( + $tempNoteHeader + .find('.note-headline-light') + .text() + .trim(), + ).toEqual(`@${currentUsername}`); + expect( + $tempNote + .find('.note-body .note-text p') + .text() + .trim(), + ).toEqual(sampleComment); }); it('should return constructed placeholder element for discussion note based on form contents', () => { @@ -886,11 +999,13 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; uniqueId, isDiscussionNote: true, currentUsername, - currentUserFullname + currentUserFullname, }); expect($tempNote.prop('nodeName')).toEqual('LI'); - expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); + expect( + $tempNote.find('.timeline-content').hasClass('discussion'), + ).toBeTruthy(); }); it('should return a escaped user name', () => { @@ -904,7 +1019,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; currentUserAvatar, }); const $tempNoteHeader = $tempNote.find('.note-header'); - expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual('Foo <script>alert("XSS")</script>'); + expect( + $tempNoteHeader + .find('.hidden-xs') + .text() + .trim(), + ).toEqual('Foo <script>alert("XSS")</script>'); }); }); @@ -927,7 +1047,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; expect($tempNote.attr('id')).toEqual(uniqueId); expect($tempNote.hasClass('being-posted')).toBeTruthy(); expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); - expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription); + expect( + $tempNote + .find('.timeline-content i') + .text() + .trim(), + ).toEqual(sampleCommandDescription); }); }); @@ -937,7 +1062,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('shows a flash message', () => { - this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline.get(0)); + this.notes.addFlash( + 'Error message', + FLASH_TYPE_ALERT, + this.notes.parentTimeline.get(0), + ); expect($('.flash-alert').is(':visible')).toBeTruthy(); }); @@ -950,7 +1079,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('hides visible flash message', () => { - this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline.get(0)); + this.notes.addFlash( + 'Error message 1', + FLASH_TYPE_ALERT, + this.notes.parentTimeline.get(0), + ); this.notes.clearFlash(); @@ -958,4 +1091,4 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); }); -}).call(window); +}.call(window)); -- cgit v1.2.1 From 04b8e00feb6a06bfef3903a9e03d24296c4734a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= Date: Wed, 21 Mar 2018 13:08:56 -0300 Subject: Use porcelain commit lookup method on CI::CreatePipelineService Before we were using a "plumbing" Gitlab::Git method that does not go through Gitaly migration checking. --- app/services/ci/create_pipeline_service.rb | 2 +- changelogs/unreleased/ci-pipeline-commit-lookup.yml | 5 +++++ lib/gitlab/git/repository.rb | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/ci-pipeline-commit-lookup.yml diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 3b3d9239086..ad27f320853 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -65,7 +65,7 @@ module Ci project.pipelines .where(ref: pipeline.ref) .where.not(id: pipeline.id) - .where.not(sha: project.repository.sha_from_ref(pipeline.ref)) + .where.not(sha: project.commit(pipeline.ref).try(:id)) .created_or_pending end diff --git a/changelogs/unreleased/ci-pipeline-commit-lookup.yml b/changelogs/unreleased/ci-pipeline-commit-lookup.yml new file mode 100644 index 00000000000..b2a1e4c2163 --- /dev/null +++ b/changelogs/unreleased/ci-pipeline-commit-lookup.yml @@ -0,0 +1,5 @@ +--- +title: Use porcelain commit lookup method on CI::CreatePipelineService +merge_request: 17911 +author: +type: fixed diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 208710b0935..c15c128507d 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -516,10 +516,6 @@ module Gitlab end end - def sha_from_ref(ref) - rev_parse_target(ref).oid - end - # Return the object that +revspec+ points to. If +revspec+ is an # annotated tag, then return the tag's target instead. def rev_parse_target(revspec) @@ -2409,6 +2405,10 @@ module Gitlab def rev_list_param(spec) spec == :all ? ['--all'] : spec end + + def sha_from_ref(ref) + rev_parse_target(ref).oid + end end end end -- cgit v1.2.1 From 1c9f354f903c3a71052889b90d15fc8223ab2975 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Thu, 22 Mar 2018 14:42:51 +0000 Subject: Prevent overflow of long instance urls during Project creation --- app/assets/stylesheets/pages/projects.scss | 50 +++++++++++++--------- app/views/import/gitlab_projects/new.html.haml | 6 +-- app/views/projects/_new_project_fields.html.haml | 4 +- ...names-group-names-covers-namespace-dropdown.yml | 5 +++ 4 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 85de0d8e70f..584b0579b72 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -9,7 +9,6 @@ .new_project, .edit-project, .import-project { - .help-block { margin-bottom: 10px; } @@ -18,18 +17,25 @@ border-radius: $border-radius-base; } - .input-group > div { + .input-group { + display: flex; - &:last-child { - padding-right: 0; + .select2-container { + display: unset; + max-width: unset; + width: unset !important; + flex-grow: 1; + } + + > div { + &:last-child { + padding-right: 0; + } } } @media (max-width: $screen-xs-max) { .input-group > div { - - margin-bottom: 14px; - &:last-child { margin-bottom: 0; } @@ -41,17 +47,24 @@ } .input-group-addon { + overflow: hidden; + text-overflow: ellipsis; + line-height: unset; + width: unset; + max-width: 50%; + text-align: left; &.static-namespace { height: 35px; border-radius: 3px; border: 1px solid $border-color; + max-width: 100%; + flex-grow: 1; } + .select2 a, + .btn-default { - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-radius: 0 $border-radius-base $border-radius-base 0; } } } @@ -290,7 +303,7 @@ font-size: 13px; font-weight: $gl-font-weight-bold; line-height: 13px; - letter-spacing: .4px; + letter-spacing: 0.4px; padding: 6px 14px; text-align: center; vertical-align: middle; @@ -443,7 +456,7 @@ a.deploy-project-label { text-decoration: none; &.disabled { - opacity: .3; + opacity: 0.3; cursor: not-allowed; } } @@ -600,26 +613,26 @@ a.deploy-project-label { } .first-column { - @media(min-width: $screen-xs-min) { + @media (min-width: $screen-xs-min) { max-width: 50%; padding-right: 30px; } - @media(max-width: $screen-xs-max) { + @media (max-width: $screen-xs-max) { max-width: 100%; width: 100%; } } .second-column { - @media(min-width: $screen-xs-min) { + @media (min-width: $screen-xs-min) { width: 50%; flex: 1; padding-left: 30px; position: relative; } - @media(max-width: $screen-xs-max) { + @media (max-width: $screen-xs-max) { max-width: 100%; width: 100%; padding-left: 0; @@ -632,7 +645,7 @@ a.deploy-project-label { } &::before { - content: "OR"; + content: 'OR'; position: absolute; left: -10px; top: 50%; @@ -656,7 +669,7 @@ a.deploy-project-label { } &::after { - content: ""; + content: ''; position: absolute; background-color: $border-color; bottom: 0; @@ -921,10 +934,7 @@ pre.light-well { border-right: solid 1px transparent; } } -} -.protected-tags-list, -.protected-branches-list { .dropdown-menu-toggle { width: 100%; max-width: 300px; diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index df5841d1911..dec85368d10 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -13,13 +13,13 @@ .form-group .input-group - if current_user.can_select_namespace? - .input-group-addon + .input-group-addon.has-tooltip{ title: root_url } = root_url = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1 - else - .input-group-addon.static-namespace - #{root_url}#{current_user.username}/ + .input-group-addon.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } + #{user_url(current_user.username)}/ = hidden_field_tag :namespace_id, value: current_user.namespace_id .form-group.col-xs-12.col-sm-6.project-path = label_tag :path, 'Project name', class: 'label-light' diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index f4b5ef1555e..241bc3dbca0 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -9,12 +9,12 @@ Project path .input-group - if current_user.can_select_namespace? - .input-group-addon + .input-group-addon.has-tooltip{ title: root_url } = root_url = f.select :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), {}, { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1} - else - .input-group-addon.static-namespace + .input-group-addon.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } #{user_url(current_user.username)}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.project-path.col-sm-6 diff --git a/changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml b/changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml new file mode 100644 index 00000000000..f7758734a6f --- /dev/null +++ b/changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Long instance urls do not overflow anymore during project creation +merge_request: 17717 +author: +type: fixed -- cgit v1.2.1 From 40b7eedc08e63f2577ad99b3d6e756a8ebc85c4a Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 18 Mar 2018 02:11:31 +0900 Subject: Fix UI breakdown for Create merge request button --- app/assets/stylesheets/pages/events.scss | 9 +++------ app/views/projects/_last_push.html.haml | 2 +- .../unreleased/44382-ui-breakdown-for-create-merge-request.yml | 5 +++++ 3 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 8871a069d5d..d9267f5cdf3 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -162,17 +162,14 @@ * Last push widget */ .event-last-push { - overflow: auto; width: 100%; + display: flex; + align-items: center; .event-last-push-text { @include str-truncated(100%); - padding: 4px 0; font-size: 13px; - float: left; - margin-right: -150px; - padding-right: 150px; - line-height: 20px; + margin-right: $gl-padding; } } diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 6f5eb828902..6a1035d2dc7 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -13,6 +13,6 @@ #{time_ago_with_tooltip(event.created_at)} - .pull-right + .flex-right = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do #{ _('Create merge request') } diff --git a/changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml b/changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml new file mode 100644 index 00000000000..dd8c0b19d5f --- /dev/null +++ b/changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml @@ -0,0 +1,5 @@ +--- +title: Fix UI breakdown for Create merge request button +merge_request: 17821 +author: Takuya Noguchi +type: fixed -- cgit v1.2.1 From f742010257b0aa00a999ef5e20a17c15f980f4c1 Mon Sep 17 00:00:00 2001 From: Tiago Botelho Date: Tue, 20 Mar 2018 13:49:51 +0000 Subject: Tracks the number of failed attempts made by a user trying to authenticate with any external authentication method --- app/controllers/omniauth_callbacks_controller.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index fff249577a2..fed8ba3ce28 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -18,6 +18,16 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end end + # Extend the standard implementation to also increment + # the number of failed sign in attempts + def failure + user = User.find_by_username(params[:username]) + + user&.increment_failed_attempts! + + super + end + # Extend the standard message generation to accept our custom exception def failure_message exception = env["omniauth.error"] -- cgit v1.2.1 From c11825a2fc6f5dd0b7bc4033a34789c7d2445970 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 22 Mar 2018 23:57:40 +0800 Subject: Force Rails to not complain about reloading Same strategy with: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17810 See: https://stackoverflow.com/a/29710188/1992201 Frankly I don't really understand how this works and I don't really care either. However I tried it and it does the job. To try this, make sure you have pending migrations, and run the server, hit the site. It would tell you that there's pending migrations, and then run migrations, and then hit the site again. Without this patch, Rails would complain that "A copy of ...", with this patch, it works without problems. --- config/application.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/application.rb b/config/application.rb index 0ff95e33a9c..13501d4bdb5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -170,7 +170,7 @@ module Gitlab ENV['GIT_TERMINAL_PROMPT'] = '0' # Gitlab Read-only middleware support - config.middleware.insert_after ActionDispatch::Flash, 'Gitlab::Middleware::ReadOnly' + config.middleware.insert_after ActionDispatch::Flash, '::Gitlab::Middleware::ReadOnly' config.generators do |g| g.factory_bot false -- cgit v1.2.1 From 161a05b963b83fe50961ef41eebc5d1c28c3110e Mon Sep 17 00:00:00 2001 From: Tiago Botelho Date: Thu, 22 Mar 2018 10:34:42 +0000 Subject: Writes specs --- app/controllers/omniauth_callbacks_controller.rb | 6 +- ...25-limit-number-of-failed-logins-using-ldap.yml | 5 + .../omniauth_callbacks_controller_spec.rb | 122 +++++++++++++-------- 3 files changed, 88 insertions(+), 45 deletions(-) create mode 100644 changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index fed8ba3ce28..5e6676ea513 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -21,9 +21,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # Extend the standard implementation to also increment # the number of failed sign in attempts def failure - user = User.find_by_username(params[:username]) + if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name) + user = User.by_login(params[:username]) - user&.increment_failed_attempts! + user&.increment_failed_attempts! + end super end diff --git a/changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml b/changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml new file mode 100644 index 00000000000..f30fea3c4a7 --- /dev/null +++ b/changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml @@ -0,0 +1,5 @@ +--- +title: Limit the number of failed logins when using LDAP for authentication +merge_request: 43525 +author: +type: added diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index 9fd129e4ee9..5f0e8c5eca9 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -10,83 +10,119 @@ describe OmniauthCallbacksController do stub_omniauth_provider(provider, context: request) end - context 'github' do + context 'when the user is on the last sign in attempt' do let(:extern_uid) { 'my-uid' } - let(:provider) { :github } - it 'allows sign in' do - post provider - - expect(request.env['warden']).to be_authenticated + before do + user.update(failed_attempts: User.maximum_attempts.pred) + subject.response = ActionDispatch::Response.new end - shared_context 'sign_up' do - let(:user) { double(email: 'new@example.com') } + context 'when using a form based provider' do + let(:provider) { :ldap } + + it 'locks the user when sign in fails' do + allow(subject).to receive(:params).and_return(ActionController::Parameters.new(username: user.username)) + request.env['omniauth.error.strategy'] = OmniAuth::Strategies::LDAP.new(nil) + + subject.send(:failure) - before do - stub_omniauth_setting(block_auto_created_users: false) + expect(user.reload).to be_access_locked end end - context 'sign up' do - include_context 'sign_up' + context 'when using a button based provider' do + let(:provider) { :github } - it 'is allowed' do - post provider + it 'does not lock the user when sign in fails' do + request.env['omniauth.error.strategy'] = OmniAuth::Strategies::GitHub.new(nil) - expect(request.env['warden']).to be_authenticated + subject.send(:failure) + + expect(user.reload).not_to be_access_locked end end + end - context 'when OAuth is disabled' do - before do - stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - settings = Gitlab::CurrentSettings.current_application_settings - settings.update(disabled_oauth_sign_in_sources: [provider.to_s]) - end + context 'strategies' do + context 'github' do + let(:extern_uid) { 'my-uid' } + let(:provider) { :github } - it 'prevents login via POST' do + it 'allows sign in' do post provider - expect(request.env['warden']).not_to be_authenticated + expect(request.env['warden']).to be_authenticated end - it 'shows warning when attempting login' do - post provider + shared_context 'sign_up' do + let(:user) { double(email: 'new@example.com') } - expect(response).to redirect_to new_user_session_path - expect(flash[:alert]).to eq('Signing in using GitHub has been disabled') + before do + stub_omniauth_setting(block_auto_created_users: false) + end end - it 'allows linking the disabled provider' do - user.identities.destroy_all - sign_in(user) + context 'sign up' do + include_context 'sign_up' + + it 'is allowed' do + post provider - expect { post provider }.to change { user.reload.identities.count }.by(1) + expect(request.env['warden']).to be_authenticated + end end - context 'sign up' do - include_context 'sign_up' + context 'when OAuth is disabled' do + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + settings = Gitlab::CurrentSettings.current_application_settings + settings.update(disabled_oauth_sign_in_sources: [provider.to_s]) + end - it 'is prevented' do + it 'prevents login via POST' do post provider expect(request.env['warden']).not_to be_authenticated end + + it 'shows warning when attempting login' do + post provider + + expect(response).to redirect_to new_user_session_path + expect(flash[:alert]).to eq('Signing in using GitHub has been disabled') + end + + it 'allows linking the disabled provider' do + user.identities.destroy_all + sign_in(user) + + expect { post provider }.to change { user.reload.identities.count }.by(1) + end + + context 'sign up' do + include_context 'sign_up' + + it 'is prevented' do + post provider + + expect(request.env['warden']).not_to be_authenticated + end + end end end - end - context 'auth0' do - let(:extern_uid) { '' } - let(:provider) { :auth0 } + context 'auth0' do + let(:extern_uid) { '' } + let(:provider) { :auth0 } - it 'does not allow sign in without extern_uid' do - post 'auth0' + it 'does not allow sign in without extern_uid' do + post 'auth0' - expect(request.env['warden']).not_to be_authenticated - expect(response.status).to eq(302) - expect(controller).to set_flash[:alert].to('Wrong extern UID provided. Make sure Auth0 is configured correctly.') + expect(request.env['warden']).not_to be_authenticated + expect(response.status).to eq(302) + expect(controller).to set_flash[:alert].to('Wrong extern UID provided. Make sure Auth0 is configured correctly.') + end end end end -- cgit v1.2.1 From 7b0e7bcf5d355a47cca57f914dd8fbbe3f07f6af Mon Sep 17 00:00:00 2001 From: Brett Walker Date: Thu, 22 Mar 2018 16:08:21 +0000 Subject: make a little more obvious what operations might need downtime or enhanced migration techniques --- doc/development/migration_style_guide.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 1e060ffd941..a211effdfa7 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -23,10 +23,6 @@ When downtime is necessary the migration has to be approved by: An up-to-date list of people holding these titles can be found at . -The document ["What Requires Downtime?"](what_requires_downtime.md) specifies -various database operations, whether they require downtime and how to -work around that whenever possible. - When writing your migrations, also consider that databases might have stale data or inconsistencies and guard for that. Try to make as few assumptions as possible about the state of the database. @@ -41,6 +37,18 @@ Migrations that make changes to the database schema (e.g. adding a column) can only be added in the monthly release, patch releases may only contain data migrations _unless_ schema changes are absolutely required to solve a problem. +## What Requires Downtime? + +The document ["What Requires Downtime?"](what_requires_downtime.md) specifies +various database operations, such as + +- [adding, dropping, and renaming columns](what_requires_downtime.md#adding-columns) +- [changing column constraints and types](what_requires_downtime.md#changing-column-constraints) +- [adding and dropping indexes, tables, and foreign keys](what_requires_downtime.md#adding-indexes) + +and whether they require downtime and how to work around that whenever possible. + + ## Downtime Tagging Every migration must specify if it requires downtime or not, and if it should -- cgit v1.2.1 From 839589fde30cff9ecf963dab775bfd9b9b2fe17b Mon Sep 17 00:00:00 2001 From: Fabian Schneider Date: Sun, 11 Mar 2018 20:56:07 +0100 Subject: Change avatar error message to include allowed file formats --- app/models/concerns/avatarable.rb | 2 +- app/models/group.rb | 6 ------ changelogs/unreleased/43771-improve-avatar-error-message.yml | 5 +++++ spec/models/group_spec.rb | 2 +- spec/models/project_spec.rb | 2 +- spec/models/user_spec.rb | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) create mode 100644 changelogs/unreleased/43771-improve-avatar-error-message.yml diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index d35e37935fb..318df11727e 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -21,7 +21,7 @@ module Avatarable def avatar_type unless self.avatar.image? - self.errors.add :avatar, "only images allowed" + errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::IMAGE_EXT.join(', ')}" end end diff --git a/app/models/group.rb b/app/models/group.rb index 8d183006c65..8e391412b52 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -189,12 +189,6 @@ class Group < Namespace owners.include?(user) && owners.size == 1 end - def avatar_type - unless self.avatar.image? - self.errors.add :avatar, "only images allowed" - end - end - def post_create_hook Gitlab::AppLogger.info("Group \"#{name}\" was created") diff --git a/changelogs/unreleased/43771-improve-avatar-error-message.yml b/changelogs/unreleased/43771-improve-avatar-error-message.yml new file mode 100644 index 00000000000..1fae10f4d1f --- /dev/null +++ b/changelogs/unreleased/43771-improve-avatar-error-message.yml @@ -0,0 +1,5 @@ +--- +title: Change avatar error message to include allowed file formats +merge_request: 17747 +author: Fabian Schneider +type: changed diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index abfc0896a41..d620943693c 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -240,7 +240,7 @@ describe Group do it "is false if avatar is html page" do group.update_attribute(:avatar, 'uploads/avatar.html') - expect(group.avatar_type).to eq(["only images allowed"]) + expect(group.avatar_type).to eq(["file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff"]) end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e970cd7dfdb..943b27f285d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -922,7 +922,7 @@ describe Project do it 'is false if avatar is html page' do project.update_attribute(:avatar, 'uploads/avatar.html') - expect(project.avatar_type).to eq(['only images allowed']) + expect(project.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff']) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5680eb24985..c61674fff13 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1222,7 +1222,7 @@ describe User do it 'is false if avatar is html page' do user.update_attribute(:avatar, 'uploads/avatar.html') - expect(user.avatar_type).to eq(['only images allowed']) + expect(user.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff']) end end -- cgit v1.2.1 From 0b96a1ad7fedd5e8f8ef2bea095cc75fba5ec713 Mon Sep 17 00:00:00 2001 From: Dennis Tang Date: Thu, 22 Mar 2018 18:05:58 +0000 Subject: Resolve "Some dropdowns have two scroll bars." --- app/assets/stylesheets/framework/dropdowns.scss | 27 ++++++++++++++++++++++++- app/assets/stylesheets/framework/sidebar.scss | 1 + app/assets/stylesheets/pages/boards.scss | 6 +++++- app/assets/stylesheets/pages/labels.scss | 8 +++++++- app/assets/stylesheets/pages/search.scss | 2 +- 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 6397757bf88..cc74cb72795 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -622,7 +622,7 @@ } .dropdown-content { - max-height: $dropdown-max-height; + max-height: 252px; overflow-y: auto; } @@ -699,6 +699,31 @@ border-radius: $border-radius-base; } +.git-revision-dropdown { + .dropdown-content { + max-height: 215px; + } +} + +.sidebar-move-issue-dropdown { + .dropdown-content { + max-height: 160px; + } +} + +.dropdown-menu-author { + .dropdown-content { + max-height: 215px; + } +} + +.dropdown-menu-labels { + .dropdown-content { + max-height: 128px; + } +} + + .dropdown-menu-due-date { .dropdown-content { max-height: 230px; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index d1d98270ad9..3dd4a613789 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -152,3 +152,4 @@ .sidebar-collapsed-icon .sidebar-collapsed-value { font-size: 12px; } + diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index c03d4c2eebf..318d3ddaece 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -31,8 +31,12 @@ .dropdown-menu-issues-board-new { width: 320px; + .open & { + max-height: 400px; + } + .dropdown-content { - max-height: 150px; + max-height: 162px; } } diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 0f49d15203b..b0852adb459 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -26,9 +26,15 @@ } } +.dropdown-menu-labels { + .dropdown-content { + max-height: 135px; + } +} + .dropdown-new-label { .dropdown-content { - max-height: 260px; + max-height: 136px; } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index c9363188505..dbde0720993 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -112,7 +112,7 @@ input[type="checkbox"]:hover { } .dropdown-content { - max-height: 350px; + max-height: 302px; } } -- cgit v1.2.1 From 7c84aab81badfeef25a996289337e431419a5123 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 22 Mar 2018 13:52:55 -0500 Subject: remove svg.config.js as it no longer exists --- .prettierignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 2375d0eda74..62fe6a45b95 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ +/config/ /node_modules/ /public/ /vendor/ karma.config.js webpack.config.js -svg.config.js -- cgit v1.2.1 From 9d45951fcaeda4f01a2e4be2480d980a3e7cd37e Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Wed, 3 Jan 2018 08:07:03 +0000 Subject: Add HTTPS-only pages Closes #28857 --- app/controllers/projects/pages_controller.rb | 22 ++++ app/helpers/projects_helper.rb | 18 +++ app/models/pages_domain.rb | 10 +- app/models/project.rb | 21 +++ .../projects/update_pages_configuration_service.rb | 6 +- app/services/projects/update_service.rb | 10 ++ app/validators/certificate_validator.rb | 2 - app/views/projects/pages/_https_only.html.haml | 10 ++ app/views/projects/pages/show.html.haml | 3 + changelogs/unreleased/pages_force_https.yml | 5 + config/routes/project.rb | 2 +- ...80102220145_add_pages_https_only_to_projects.rb | 9 ++ ...19_change_default_value_for_pages_https_only.rb | 13 ++ db/schema.rb | 1 + spec/controllers/projects/pages_controller_spec.rb | 37 ++++++ .../projects/pages_domains_controller_spec.rb | 4 +- spec/factories/pages_domains.rb | 48 +++---- spec/features/projects/pages_spec.rb | 90 ++++++++++--- .../gitlab/import_export/safe_model_attributes.yml | 1 + spec/models/pages_domain_spec.rb | 146 ++++++++++++++------- spec/models/project_spec.rb | 45 +++++++ spec/requests/api/pages_domains_spec.rb | 14 +- spec/services/projects/update_service_spec.rb | 21 +++ spec/spec_helper.rb | 16 +++ 24 files changed, 447 insertions(+), 107 deletions(-) create mode 100644 app/views/projects/pages/_https_only.html.haml create mode 100644 changelogs/unreleased/pages_force_https.yml create mode 100644 db/migrate/20180102220145_add_pages_https_only_to_projects.rb create mode 100644 db/migrate/20180109183319_change_default_value_for_pages_https_only.rb diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index d421b1a8eb5..cae6e2c40b8 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -21,4 +21,26 @@ class Projects::PagesController < Projects::ApplicationController end end end + + def update + result = Projects::UpdateService.new(@project, current_user, project_params).execute + + respond_to do |format| + format.html do + if result[:status] == :success + flash[:notice] = 'Your changes have been saved' + else + flash[:alert] = 'Something went wrong on our end' + end + + redirect_to project_pages_path(@project) + end + end + end + + private + + def project_params + params.require(:project).permit(:pages_https_only) + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index da9fe734f1c..15f48e43a28 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -531,4 +531,22 @@ module ProjectsHelper def can_show_last_commit_in_list?(project) can?(current_user, :read_cross_project) && project.commit end + + def pages_https_only_disabled? + !@project.pages_domains.all?(&:https?) + end + + def pages_https_only_title + return unless pages_https_only_disabled? + + "You must enable HTTPS for all your domains first" + end + + def pages_https_only_label_class + if pages_https_only_disabled? + "list-label disabled" + else + "list-label" + end + end end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 588bd50ed77..2e478a24778 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -6,8 +6,10 @@ class PagesDomain < ActiveRecord::Base validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, uniqueness: { case_sensitive: false } - validates :certificate, certificate: true, allow_nil: true, allow_blank: true - validates :key, certificate_key: true, allow_nil: true, allow_blank: true + validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' }, if: ->(domain) { domain.project&.pages_https_only? } + validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? } + validates :key, presence: { message: 'must be present if HTTPS-only is enabled' }, if: ->(domain) { domain.project&.pages_https_only? } + validates :key, certificate_key: true, if: ->(domain) { domain.key.present? } validates :verification_code, presence: true, allow_blank: false validate :validate_pages_domain @@ -46,6 +48,10 @@ class PagesDomain < ActiveRecord::Base !Gitlab::CurrentSettings.pages_domain_verification_enabled? || enabled_until.present? end + def https? + certificate.present? + end + def to_param domain end diff --git a/app/models/project.rb b/app/models/project.rb index 250680e2a2c..48a81ddb82e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -267,6 +267,7 @@ class Project < ActiveRecord::Base validate :visibility_level_allowed_by_group validate :visibility_level_allowed_as_fork validate :check_wiki_path_conflict + validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } @@ -737,6 +738,26 @@ class Project < ActiveRecord::Base end end + def pages_https_only + return false unless Gitlab.config.pages.external_https + + super + end + + def pages_https_only? + return false unless Gitlab.config.pages.external_https + + super + end + + def validate_pages_https_only + return unless pages_https_only? + + unless pages_domains.all?(&:https?) + errors.add(:pages_https_only, "cannot be enabled unless all domains have TLS certificates") + end + end + def to_param if persisted? && errors.include?(:path) path_was diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index 52ff64cc938..25017c5cbe3 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -18,7 +18,8 @@ module Projects def pages_config { - domains: pages_domains_config + domains: pages_domains_config, + https_only: project.pages_https_only? } end @@ -27,7 +28,8 @@ module Projects { domain: domain.domain, certificate: domain.certificate, - key: domain.key + key: domain.key, + https_only: project.pages_https_only? && domain.https? } end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 5f2615a2c01..679f4a9cb62 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -24,6 +24,8 @@ module Projects system_hook_service.execute_hooks_for(project, :update) end + update_pages_config if changing_pages_https_only? + success else model_errors = project.errors.full_messages.to_sentence @@ -67,5 +69,13 @@ module Projects log_error("Could not create wiki for #{project.full_name}") Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki') end + + def update_pages_config + Projects::UpdatePagesConfigurationService.new(project).execute + end + + def changing_pages_https_only? + project.previous_changes.include?(:pages_https_only) + end end end diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb index 5239e70a326..b0c9a1b92a4 100644 --- a/app/validators/certificate_validator.rb +++ b/app/validators/certificate_validator.rb @@ -16,8 +16,6 @@ class CertificateValidator < ActiveModel::EachValidator private def valid_certificate_pem?(value) - return false unless value - OpenSSL::X509::Certificate.new(value).present? rescue OpenSSL::X509::CertificateError false diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml new file mode 100644 index 00000000000..6a3ffce949f --- /dev/null +++ b/app/views/projects/pages/_https_only.html.haml @@ -0,0 +1,10 @@ += form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f| + = f.check_box :pages_https_only, class: 'pull-left', disabled: pages_https_only_disabled? + + .prepend-left-20 + = f.label :pages_https_only, class: pages_https_only_label_class do + %strong Force domains with SSL certificates to use HTTPS + + - unless pages_https_only_disabled? + .prepend-top-10 + = f.submit 'Save', class: 'btn btn-success' diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 04e647c0dc6..f17d9d24db6 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -13,6 +13,9 @@ Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group. +- if Gitlab.config.pages.external_https + = render 'https_only' + %hr.clearfix = render 'access' diff --git a/changelogs/unreleased/pages_force_https.yml b/changelogs/unreleased/pages_force_https.yml new file mode 100644 index 00000000000..da7e29087f3 --- /dev/null +++ b/changelogs/unreleased/pages_force_https.yml @@ -0,0 +1,5 @@ +--- +title: Add HTTPS-only pages +merge_request: 16273 +author: rfwatson +type: added diff --git a/config/routes/project.rb b/config/routes/project.rb index c803737d40b..f50b9aded8d 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -52,7 +52,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resource :pages, only: [:show, :destroy] do + resource :pages, only: [:show, :update, :destroy] do resources :domains, except: :index, controller: 'pages_domains', constraints: { id: %r{[^/]+} } do member do post :verify diff --git a/db/migrate/20180102220145_add_pages_https_only_to_projects.rb b/db/migrate/20180102220145_add_pages_https_only_to_projects.rb new file mode 100644 index 00000000000..ef6bc6896c0 --- /dev/null +++ b/db/migrate/20180102220145_add_pages_https_only_to_projects.rb @@ -0,0 +1,9 @@ +class AddPagesHttpsOnlyToProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :projects, :pages_https_only, :boolean + end +end diff --git a/db/migrate/20180109183319_change_default_value_for_pages_https_only.rb b/db/migrate/20180109183319_change_default_value_for_pages_https_only.rb new file mode 100644 index 00000000000..c242e1b0d24 --- /dev/null +++ b/db/migrate/20180109183319_change_default_value_for_pages_https_only.rb @@ -0,0 +1,13 @@ +class ChangeDefaultValueForPagesHttpsOnly < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + change_column_default :projects, :pages_https_only, true + end + + def down + change_column_default :projects, :pages_https_only, nil + end +end diff --git a/db/schema.rb b/db/schema.rb index 56116a2d241..1be0570f85a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1513,6 +1513,7 @@ ActiveRecord::Schema.define(version: 20180320182229) do t.boolean "merge_requests_ff_only_enabled", default: false t.boolean "merge_requests_rebase_enabled", default: false, null: false t.integer "jobs_cache_index" + t.boolean "pages_https_only", default: true end add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb index 4705c50de7e..11f54eef531 100644 --- a/spec/controllers/projects/pages_controller_spec.rb +++ b/spec/controllers/projects/pages_controller_spec.rb @@ -65,4 +65,41 @@ describe Projects::PagesController do end end end + + describe 'PATCH update' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + project: { pages_https_only: false } + } + end + + let(:update_service) { double(execute: { status: :success }) } + + before do + allow(Projects::UpdateService).to receive(:new) { update_service } + end + + it 'returns 302 status' do + patch :update, request_params + + expect(response).to have_gitlab_http_status(:found) + end + + it 'redirects back to the pages settings' do + patch :update, request_params + + expect(response).to redirect_to(project_pages_path(project)) + end + + it 'calls the update service' do + expect(Projects::UpdateService) + .to receive(:new) + .with(project, user, request_params[:project]) + .and_return(update_service) + + patch :update, request_params + end + end end diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 83a3799e883..d4058a5c515 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -13,7 +13,7 @@ describe Projects::PagesDomainsController do end let(:pages_domain_params) do - build(:pages_domain, :with_certificate, :with_key, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain) + build(:pages_domain, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain) end before do @@ -68,7 +68,7 @@ describe Projects::PagesDomainsController do end let(:pages_domain_params) do - attributes_for(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate) + attributes_for(:pages_domain).slice(:key, :certificate) end let(:params) do diff --git a/spec/factories/pages_domains.rb b/spec/factories/pages_domains.rb index 35b44e1c52e..20671da016e 100644 --- a/spec/factories/pages_domains.rb +++ b/spec/factories/pages_domains.rb @@ -4,25 +4,7 @@ FactoryBot.define do verified_at { Time.now } enabled_until { 1.week.from_now } - trait :disabled do - verified_at nil - enabled_until nil - end - - trait :unverified do - verified_at nil - end - - trait :reverify do - enabled_until { 1.hour.from_now } - end - - trait :expired do - enabled_until { 1.hour.ago } - end - - trait :with_certificate do - certificate '-----BEGIN CERTIFICATE----- + certificate '-----BEGIN CERTIFICATE----- MIICGzCCAYSgAwIBAgIBATANBgkqhkiG9w0BAQUFADAbMRkwFwYDVQQDExB0ZXN0 LWNlcnRpZmljYXRlMB4XDTE2MDIxMjE0MzIwMFoXDTIwMDQxMjE0MzIwMFowGzEZ MBcGA1UEAxMQdGVzdC1jZXJ0aWZpY2F0ZTCBnzANBgkqhkiG9w0BAQEFAAOBjQAw @@ -36,10 +18,8 @@ joZp2JHYvNlTPkRJ/J4TcXxBTJmArcQgTIuNoBtC+0A/SwdK4MfTCUY4vNWNdese 5A4K65Nb7Oh1AdQieTBHNXXCdyFsva9/ScfQGEl7p55a52jOPs0StPd7g64uvjlg YHi2yesCrOvVXt+lgPTd -----END CERTIFICATE-----' - end - trait :with_key do - key '-----BEGIN PRIVATE KEY----- + key '-----BEGIN PRIVATE KEY----- MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB @@ -55,6 +35,30 @@ EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx 63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi nNp/xedE1YxutQ== -----END PRIVATE KEY-----' + + trait :disabled do + verified_at nil + enabled_until nil + end + + trait :unverified do + verified_at nil + end + + trait :reverify do + enabled_until { 1.hour.from_now } + end + + trait :expired do + enabled_until { 1.hour.ago } + end + + trait :without_certificate do + certificate nil + end + + trait :without_key do + key nil end trait :with_missing_chain do diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index 233d2e67b9d..020738ae865 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -40,11 +40,6 @@ feature 'Pages' do end context 'when support for external domains is disabled' do - before do - allow(Gitlab.config.pages).to receive(:external_http).and_return(nil) - allow(Gitlab.config.pages).to receive(:external_https).and_return(nil) - end - it 'renders message that support is disabled' do visit project_pages_path(project) @@ -52,7 +47,9 @@ feature 'Pages' do end end - context 'when pages are exposed on external HTTP address' do + context 'when pages are exposed on external HTTP address', :http_pages_enabled do + given(:project) { create(:project, pages_https_only: false) } + shared_examples 'adds new domain' do it 'adds new domain' do visit new_project_pages_domain_path(project) @@ -64,11 +61,6 @@ feature 'Pages' do end end - before do - allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) - allow(Gitlab.config.pages).to receive(:external_https).and_return(nil) - end - it 'allows to add new domain' do visit project_pages_path(project) @@ -80,13 +72,13 @@ feature 'Pages' do context 'when project in group namespace' do it_behaves_like 'adds new domain' do let(:group) { create :group } - let(:project) { create :project, namespace: group } + let(:project) { create(:project, namespace: group, pages_https_only: false) } end end context 'when pages domain is added' do before do - project.pages_domains.create!(domain: 'my.test.domain.com') + create(:pages_domain, project: project, domain: 'my.test.domain.com') visit new_project_pages_domain_path(project) end @@ -104,7 +96,7 @@ feature 'Pages' do end end - context 'when pages are exposed on external HTTPS address' do + context 'when pages are exposed on external HTTPS address', :https_pages_enabled do let(:certificate_pem) do <<~PEM -----BEGIN CERTIFICATE----- @@ -145,11 +137,6 @@ feature 'Pages' do KEY end - before do - allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) - allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443']) - end - it 'adds new domain with certificate' do visit new_project_pages_domain_path(project) @@ -163,7 +150,7 @@ feature 'Pages' do describe 'updating the certificate for an existing domain' do let!(:domain) do - create(:pages_domain, :with_key, :with_certificate, project: project) + create(:pages_domain, project: project) end it 'allows the certificate to be updated' do @@ -237,6 +224,69 @@ feature 'Pages' do it_behaves_like 'no pages deployed' end + describe 'HTTPS settings', :js, :https_pages_enabled do + background do + project.namespace.update(owner: user) + + allow_any_instance_of(Project).to receive(:pages_deployed?) { true } + end + + scenario 'tries to change the setting' do + visit project_pages_path(project) + expect(page).to have_content("Force domains with SSL certificates to use HTTPS") + + uncheck :project_pages_https_only + + click_button 'Save' + + expect(page).to have_text('Your changes have been saved') + expect(page).not_to have_checked_field('project_pages_https_only') + end + + context 'setting could not be updated' do + before do + allow_any_instance_of(Projects::UpdateService) + .to receive(:execute) + .and_return(status: :error) + end + + scenario 'tries to change the setting' do + visit project_pages_path(project) + + uncheck :project_pages_https_only + + click_button 'Save' + + expect(page).to have_text('Something went wrong on our end') + end + end + + context 'non-HTTPS domain exists' do + given(:project) { create(:project, pages_https_only: false) } + + before do + create(:pages_domain, :without_key, :without_certificate, project: project) + end + + scenario 'the setting is disabled' do + visit project_pages_path(project) + + expect(page).to have_field(:project_pages_https_only, disabled: true) + expect(page).not_to have_button('Save') + end + end + + context 'HTTPS pages are disabled', :https_pages_disabled do + scenario 'the setting is unavailable' do + visit project_pages_path(project) + + expect(page).not_to have_field(:project_pages_https_only) + expect(page).not_to have_content('Force domains with SSL certificates to use HTTPS') + expect(page).not_to have_button('Save') + end + end + end + describe 'Remove page' do context 'when user is the owner' do let(:project) { create :project, :repository } diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0b938892da5..44e4c6ff94b 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -458,6 +458,7 @@ Project: - merge_requests_ff_only_enabled - merge_requests_rebase_enabled - jobs_cache_index +- pages_https_only Author: - name ProjectFeature: diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 95713d8b85b..4b85c5e8720 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -18,24 +18,63 @@ describe PagesDomain do it { is_expected.to validate_uniqueness_of(:domain).case_insensitive } end - { - 'my.domain.com' => true, - '123.456.789' => true, - '0x12345.com' => true, - '0123123' => true, - '_foo.com' => false, - 'reserved.com' => false, - 'a.reserved.com' => false, - nil => false - }.each do |value, validity| - context "domain #{value.inspect} validity" do - before do - allow(Settings.pages).to receive(:host).and_return('reserved.com') + describe "hostname" do + { + 'my.domain.com' => true, + '123.456.789' => true, + '0x12345.com' => true, + '0123123' => true, + '_foo.com' => false, + 'reserved.com' => false, + 'a.reserved.com' => false, + nil => false + }.each do |value, validity| + context "domain #{value.inspect} validity" do + before do + allow(Settings.pages).to receive(:host).and_return('reserved.com') + end + + let(:domain) { value } + + it { expect(pages_domain.valid?).to eq(validity) } + end + end + end + + describe "HTTPS-only" do + using RSpec::Parameterized::TableSyntax + + let(:domain) { 'my.domain.com' } + + let(:project) do + instance_double(Project, pages_https_only?: pages_https_only) + end + + let(:pages_domain) do + build(:pages_domain, certificate: certificate, key: key).tap do |pd| + allow(pd).to receive(:project).and_return(project) + pd.valid? end + end - let(:domain) { value } + where(:pages_https_only, :certificate, :key, :errors_on) do + attributes = attributes_for(:pages_domain) + cert, key = attributes.fetch_values(:certificate, :key) + + true | nil | nil | %i(certificate key) + true | cert | nil | %i(key) + true | nil | key | %i(certificate key) + true | cert | key | [] + false | nil | nil | [] + false | cert | nil | %i(key) + false | nil | key | %i(key) + false | cert | key | [] + end - it { expect(pages_domain.valid?).to eq(validity) } + with_them do + it "is adds the expected errors" do + expect(pages_domain.errors.keys).to eq errors_on + end end end end @@ -43,26 +82,26 @@ describe PagesDomain do describe 'validate certificate' do subject { domain } - context 'when only certificate is specified' do - let(:domain) { build(:pages_domain, :with_certificate) } + context 'with matching key' do + let(:domain) { build(:pages_domain) } - it { is_expected.not_to be_valid } + it { is_expected.to be_valid } end - context 'when only key is specified' do - let(:domain) { build(:pages_domain, :with_key) } + context 'when no certificate is specified' do + let(:domain) { build(:pages_domain, :without_certificate) } it { is_expected.not_to be_valid } end - context 'with matching key' do - let(:domain) { build(:pages_domain, :with_certificate, :with_key) } + context 'when no key is specified' do + let(:domain) { build(:pages_domain, :without_key) } - it { is_expected.to be_valid } + it { is_expected.not_to be_valid } end context 'for not matching key' do - let(:domain) { build(:pages_domain, :with_missing_chain, :with_key) } + let(:domain) { build(:pages_domain, :with_missing_chain) } it { is_expected.not_to be_valid } end @@ -103,30 +142,26 @@ describe PagesDomain do describe '#url' do subject { domain.url } - context 'without the certificate' do - let(:domain) { build(:pages_domain, certificate: '') } + let(:domain) { build(:pages_domain) } - it { is_expected.to eq("http://#{domain.domain}") } - end + it { is_expected.to eq("https://#{domain.domain}") } - context 'with a certificate' do - let(:domain) { build(:pages_domain, :with_certificate) } + context 'without the certificate' do + let(:domain) { build(:pages_domain, :without_certificate) } - it { is_expected.to eq("https://#{domain.domain}") } + it { is_expected.to eq("http://#{domain.domain}") } end end describe '#has_matching_key?' do subject { domain.has_matching_key? } - context 'for matching key' do - let(:domain) { build(:pages_domain, :with_certificate, :with_key) } + let(:domain) { build(:pages_domain) } - it { is_expected.to be_truthy } - end + it { is_expected.to be_truthy } context 'for invalid key' do - let(:domain) { build(:pages_domain, :with_missing_chain, :with_key) } + let(:domain) { build(:pages_domain, :with_missing_chain) } it { is_expected.to be_falsey } end @@ -136,7 +171,7 @@ describe PagesDomain do subject { domain.has_intermediates? } context 'for self signed' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } it { is_expected.to be_truthy } end @@ -162,7 +197,7 @@ describe PagesDomain do subject { domain.expired? } context 'for valid' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } it { is_expected.to be_falsey } end @@ -175,7 +210,7 @@ describe PagesDomain do end describe '#subject' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } subject { domain.subject } @@ -183,7 +218,7 @@ describe PagesDomain do end describe '#certificate_text' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } subject { domain.certificate_text } @@ -191,6 +226,18 @@ describe PagesDomain do it { is_expected.not_to be_empty } end + describe "#https?" do + context "when a certificate is present" do + subject { build(:pages_domain) } + it { is_expected.to be_https } + end + + context "when no certificate is present" do + subject { build(:pages_domain, :without_certificate) } + it { is_expected.not_to be_https } + end + end + describe '#update_daemon' do it 'runs when the domain is created' do domain = build(:pages_domain) @@ -267,29 +314,30 @@ describe PagesDomain do end context 'TLS configuration' do - set(:domain_with_tls) { create(:pages_domain, :with_key, :with_certificate) } + set(:domain_without_tls) { create(:pages_domain, :without_certificate, :without_key) } + set(:domain) { create(:pages_domain) } - let(:cert1) { domain_with_tls.certificate } + let(:cert1) { domain.certificate } let(:cert2) { cert1 + ' ' } - let(:key1) { domain_with_tls.key } + let(:key1) { domain.key } let(:key2) { key1 + ' ' } it 'updates when added' do - expect(domain).to receive(:update_daemon) + expect(domain_without_tls).to receive(:update_daemon) - domain.update!(key: key1, certificate: cert1) + domain_without_tls.update!(key: key1, certificate: cert1) end it 'updates when changed' do - expect(domain_with_tls).to receive(:update_daemon) + expect(domain).to receive(:update_daemon) - domain_with_tls.update!(key: key2, certificate: cert2) + domain.update!(key: key2, certificate: cert2) end it 'updates when removed' do - expect(domain_with_tls).to receive(:update_daemon) + expect(domain).to receive(:update_daemon) - domain_with_tls.update!(key: nil, certificate: nil) + domain.update!(key: nil, certificate: nil) end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4cf8d861595..c522ab7c447 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3479,4 +3479,49 @@ describe Project do end end end + + describe "#pages_https_only?" do + subject { build(:project) } + + context "when HTTPS pages are disabled" do + it { is_expected.not_to be_pages_https_only } + end + + context "when HTTPS pages are enabled", :https_pages_enabled do + it { is_expected.to be_pages_https_only } + end + end + + describe "#pages_https_only? validation", :https_pages_enabled do + subject(:project) do + # set-up dirty object: + create(:project, pages_https_only: false).tap do |p| + p.pages_https_only = true + end + end + + context "when no domains are associated" do + it { is_expected.to be_valid } + end + + context "when domains including keys and certificates are associated" do + before do + allow(project) + .to receive(:pages_domains) + .and_return([instance_double(PagesDomain, https?: true)]) + end + + it { is_expected.to be_valid } + end + + context "when domains including no keys or certificates are associated" do + before do + allow(project) + .to receive(:pages_domains) + .and_return([instance_double(PagesDomain, https?: false)]) + end + + it { is_expected.not_to be_valid } + end + end end diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb index dc3a116c060..a9ccbb32666 100644 --- a/spec/requests/api/pages_domains_spec.rb +++ b/spec/requests/api/pages_domains_spec.rb @@ -1,17 +1,17 @@ require 'rails_helper' describe API::PagesDomains do - set(:project) { create(:project, path: 'my.project') } + set(:project) { create(:project, path: 'my.project', pages_https_only: false) } set(:user) { create(:user) } set(:admin) { create(:admin) } - set(:pages_domain) { create(:pages_domain, domain: 'www.domain.test', project: project) } - set(:pages_domain_secure) { create(:pages_domain, :with_certificate, :with_key, domain: 'ssl.domain.test', project: project) } - set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, :with_key, domain: 'expired.domain.test', project: project) } + set(:pages_domain) { create(:pages_domain, :without_key, :without_certificate, domain: 'www.domain.test', project: project) } + set(:pages_domain_secure) { create(:pages_domain, domain: 'ssl.domain.test', project: project) } + set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, domain: 'expired.domain.test', project: project) } - let(:pages_domain_params) { build(:pages_domain, domain: 'www.other-domain.test').slice(:domain) } - let(:pages_domain_secure_params) { build(:pages_domain, :with_certificate, :with_key, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) } - let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, :with_key, project: project).slice(:domain, :certificate, :key) } + let(:pages_domain_params) { build(:pages_domain, :without_key, :without_certificate, domain: 'www.other-domain.test').slice(:domain) } + let(:pages_domain_secure_params) { build(:pages_domain, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) } + let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, project: project).slice(:domain, :certificate, :key) } let(:pages_domain_secure_missing_chain_params) {build(:pages_domain, :with_missing_chain, project: project).slice(:certificate) } let(:route) { "/projects/#{project.id}/pages/domains" } diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index f3f97b6b921..497c1949256 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -241,6 +241,27 @@ describe Projects::UpdateService do }) end end + + context 'when updating #pages_https_only', :https_pages_enabled do + subject(:call_service) do + update_project(project, admin, pages_https_only: false) + end + + it 'updates the attribute' do + expect { call_service } + .to change { project.pages_https_only? } + .to(false) + end + + it 'calls Projects::UpdatePagesConfigurationService' do + expect(Projects::UpdatePagesConfigurationService) + .to receive(:new) + .with(project) + .and_call_original + + call_service + end + end end describe '#run_auto_devops_pipeline?' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9f6f0204a16..5051cd34564 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -197,6 +197,22 @@ RSpec.configure do |config| Ability.allowed?(*args) end end + + config.before(:each, :http_pages_enabled) do |_| + allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) + end + + config.before(:each, :https_pages_enabled) do |_| + allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443']) + end + + config.before(:each, :http_pages_disabled) do |_| + allow(Gitlab.config.pages).to receive(:external_http).and_return(false) + end + + config.before(:each, :https_pages_disabled) do |_| + allow(Gitlab.config.pages).to receive(:external_https).and_return(false) + end end # add simpler way to match asset paths containing digest strings -- cgit v1.2.1 From 907045b268c977bc97f2f6d54980ae4c65efa19c Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 22 Mar 2018 14:10:38 -0500 Subject: enable scope hoisting only in produciton (it interferes with HMR) --- config/webpack.config.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/webpack.config.js b/config/webpack.config.js index f5fb7de6176..213b05e653c 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -222,9 +222,6 @@ const config = { names: ['main', 'common', 'webpack_runtime'], }), - // enable scope hoisting - new webpack.optimize.ModuleConcatenationPlugin(), - // copy pre-compiled vendor libraries verbatim new CopyWebpackPlugin([ { @@ -281,6 +278,7 @@ if (IS_PRODUCTION) { minimize: true, debug: false, }), + new webpack.optimize.ModuleConcatenationPlugin(), new webpack.optimize.UglifyJsPlugin({ sourceMap: true, }), -- cgit v1.2.1 From 9ab43aa762d2f0b69a400da0d9e992f232179002 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Fri, 16 Mar 2018 13:19:46 +0100 Subject: Add read-only banner to all pages When the database is in a read-only state, display a banner on each page informing the user they cannot write to that GitLab instance. Closes gitlab-org/gitlab-ce#43937. --- app/controllers/admin/application_controller.rb | 14 ------------ app/helpers/application_helper.rb | 7 ++++++ app/views/layouts/_page.html.haml | 1 + .../layouts/header/_read_only_banner.html.haml | 7 ++++++ .../unreleased/tc-re-add-read-only-banner.yml | 5 +++++ spec/features/read_only_spec.rb | 25 ++++++++++++++++++++++ 6 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 app/views/layouts/header/_read_only_banner.html.haml create mode 100644 changelogs/unreleased/tc-re-add-read-only-banner.yml create mode 100644 spec/features/read_only_spec.rb diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index c27f2ee3c09..a4648b33cfa 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -3,23 +3,9 @@ # Automatically sets the layout and ensures an administrator is logged in class Admin::ApplicationController < ApplicationController before_action :authenticate_admin! - before_action :display_read_only_information layout 'admin' def authenticate_admin! render_404 unless current_user.admin? end - - def display_read_only_information - return unless Gitlab::Database.read_only? - - flash.now[:notice] = read_only_message - end - - private - - # Overridden in EE - def read_only_message - _('You are on a read-only GitLab instance.') - end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3ddf8eb3369..701be97ee96 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -323,4 +323,11 @@ module ApplicationHelper def locale_path asset_path("locale/#{Gitlab::I18n.locale}/app.js") end + + # Overridden in EE + def read_only_message + return unless Gitlab::Database.read_only? + + _('You are on a read-only GitLab instance.') + end end diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f0963cf9da8..f67a8878c80 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -6,6 +6,7 @@ .mobile-overlay .alert-wrapper = render "layouts/broadcast" + = render 'layouts/header/read_only_banner' = yield :flash_message - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" diff --git a/app/views/layouts/header/_read_only_banner.html.haml b/app/views/layouts/header/_read_only_banner.html.haml new file mode 100644 index 00000000000..f3d563c362f --- /dev/null +++ b/app/views/layouts/header/_read_only_banner.html.haml @@ -0,0 +1,7 @@ +- message = read_only_message +- if message + .flash-container.flash-container-page + .flash-notice + %div{ class: (container_class) } + %span + = message diff --git a/changelogs/unreleased/tc-re-add-read-only-banner.yml b/changelogs/unreleased/tc-re-add-read-only-banner.yml new file mode 100644 index 00000000000..35bcd7e184e --- /dev/null +++ b/changelogs/unreleased/tc-re-add-read-only-banner.yml @@ -0,0 +1,5 @@ +--- +title: Add read-only banner to all pages +merge_request: 17798 +author: +type: fixed diff --git a/spec/features/read_only_spec.rb b/spec/features/read_only_spec.rb new file mode 100644 index 00000000000..8bfaf558466 --- /dev/null +++ b/spec/features/read_only_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +describe 'read-only message' do + set(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'shows read-only banner when database is read-only' do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + + visit root_dashboard_path + + expect(page).to have_content('You are on a read-only GitLab instance.') + end + + it 'does not show read-only banner when database is able to read-write' do + allow(Gitlab::Database).to receive(:read_only?).and_return(false) + + visit root_dashboard_path + + expect(page).not_to have_content('You are on a read-only GitLab instance.') + end +end -- cgit v1.2.1 From 1f2cc29c8f703cedc03645bc365fda068f33a86b Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Thu, 22 Mar 2018 21:22:20 +0100 Subject: Fix EncodingHelper#clean blowing up on UTF-16BE strings Closes gitaly#1101 --- lib/gitlab/encoding_helper.rb | 2 +- spec/lib/gitlab/encoding_helper_spec.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb index 6659efa0961..0b8f6cfe3cb 100644 --- a/lib/gitlab/encoding_helper.rb +++ b/lib/gitlab/encoding_helper.rb @@ -90,7 +90,7 @@ module Gitlab end def clean(message) - message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "") + message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "".encode("UTF-16BE")) .encode("UTF-8") .gsub("\0".encode("UTF-8"), "") end diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 83d431a7458..e68c9850f6b 100644 --- a/spec/lib/gitlab/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -161,6 +161,11 @@ describe Gitlab::EncodingHelper do 'removes invalid bytes from ASCII-8bit encoded multibyte string.', "Lorem ipsum\xC3\n dolor sit amet, xy\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg".force_encoding('ASCII-8BIT'), "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg" + ], + [ + 'handles UTF-16BE encoded strings', + "\xFE\xFF\x00\x41".force_encoding('ASCII-8BIT'), # An "A" prepended with UTF-16 BOM + "\xEF\xBB\xBFA" # An "A" prepended with UTF-8 BOM ] ].each do |description, test_string, xpect| it description do -- cgit v1.2.1 From 6e7064dc8a3bc2aff1e166bd171b3ca828c08e38 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 22 Mar 2018 13:41:35 -0700 Subject: Bump loofah to 2.2.2 and rails-html-sanitizer to 1.0.4 See: * https://github.com/rails/rails-html-sanitizer/releases * https://github.com/flavorjones/loofah/releases --- Gemfile | 2 +- Gemfile.lock | 10 ++++++---- changelogs/unreleased/sh-update-loofah.yml | 5 +++++ 3 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/sh-update-loofah.yml diff --git a/Gemfile b/Gemfile index 2bd6acede79..149ae1fac0d 100644 --- a/Gemfile +++ b/Gemfile @@ -231,7 +231,7 @@ gem 'sanitize', '~> 2.0' gem 'babosa', '~> 1.0.2' # Sanitizes SVG input -gem 'loofah', '~> 2.0.3' +gem 'loofah', '~> 2.2' # Working with license gem 'licensee', '~> 8.9' diff --git a/Gemfile.lock b/Gemfile.lock index aed9f1d6b30..a92843f32d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,6 +143,7 @@ GEM connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) + crass (1.0.3) creole (0.5.0) css_parser (1.5.0) addressable @@ -485,7 +486,8 @@ GEM actionpack (>= 4, < 5.2) activesupport (>= 4, < 5.2) railties (>= 4, < 5.2) - loofah (2.0.3) + loofah (2.2.2) + crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.0) mini_mime (>= 0.1.1) @@ -679,8 +681,8 @@ GEM activesupport (>= 4.2.0, < 5.0) nokogiri (~> 1.6) rails-deprecated_sanitizer (>= 1.0.1) - rails-html-sanitizer (1.0.3) - loofah (~> 2.0) + rails-html-sanitizer (1.0.4) + loofah (~> 2.2, >= 2.2.2) rails-i18n (4.0.9) i18n (~> 0.7) railties (~> 4.0) @@ -1093,7 +1095,7 @@ DEPENDENCIES license_finder (~> 3.1) licensee (~> 8.9) lograge (~> 0.5) - loofah (~> 2.0.3) + loofah (~> 2.2) mail_room (~> 0.9.1) method_source (~> 0.8) minitest (~> 5.7.0) diff --git a/changelogs/unreleased/sh-update-loofah.yml b/changelogs/unreleased/sh-update-loofah.yml new file mode 100644 index 00000000000..6aff0f91939 --- /dev/null +++ b/changelogs/unreleased/sh-update-loofah.yml @@ -0,0 +1,5 @@ +--- +title: Bump rails-html-sanitizer to 1.0.4 +merge_request: +author: +type: security -- cgit v1.2.1 From e06f678cd8687b600f1d6a8f9f397fa1c74136a2 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 22 Mar 2018 15:44:35 -0700 Subject: Rollback to a set version instead of an arbitrary number of steps The previously hard-coded value can fail when new migrations are introduced since multiple migrations may need to be rolled back atomically. Closes #40302 --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 724e37141d6..70f41e4dc98 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -544,7 +544,7 @@ migration:path-mysql: .db-rollback: &db-rollback <<: *dedicated-no-docs-pull-cache-job script: - - bundle exec rake db:rollback STEP=119 + - bundle exec rake db:migrate VERSION=20170523121229 - bundle exec rake db:migrate db:rollback-pg: -- cgit v1.2.1 From f4218f277adf59a76da43ca534037feff024614d Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Thu, 22 Mar 2018 16:49:13 -0700 Subject: Increase the memory limits used in the unicorn killer These limits were updated in our docs, and in omnibus some time ago. But the defaults in the source-install were missed. --- changelogs/unreleased/increase-unicorn-memory-killer-limits.yml | 5 +++++ config.ru | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/increase-unicorn-memory-killer-limits.yml diff --git a/changelogs/unreleased/increase-unicorn-memory-killer-limits.yml b/changelogs/unreleased/increase-unicorn-memory-killer-limits.yml new file mode 100644 index 00000000000..6d7d2df4f4a --- /dev/null +++ b/changelogs/unreleased/increase-unicorn-memory-killer-limits.yml @@ -0,0 +1,5 @@ +--- +title: Increase the memory limits used in the unicorn killer +merge_request: 17948 +author: +type: other diff --git a/config.ru b/config.ru index 7b15939c6ff..405d01863ac 100644 --- a/config.ru +++ b/config.ru @@ -7,8 +7,8 @@ if defined?(Unicorn) # Unicorn self-process killer require 'unicorn/worker_killer' - min = (ENV['GITLAB_UNICORN_MEMORY_MIN'] || 300 * 1 << 20).to_i - max = (ENV['GITLAB_UNICORN_MEMORY_MAX'] || 350 * 1 << 20).to_i + min = (ENV['GITLAB_UNICORN_MEMORY_MIN'] || 400 * 1 << 20).to_i + max = (ENV['GITLAB_UNICORN_MEMORY_MAX'] || 650 * 1 << 20).to_i # Max memory size (RSS) per worker use Unicorn::WorkerKiller::Oom, min, max -- cgit v1.2.1 From 204ab7c9f88e19eea96ff64e39ae81cdcb83bada Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Tue, 20 Mar 2018 09:29:15 +0100 Subject: Fix issuable state indicator Now the issuable reference check works only in project scope, if we reference an issuable from a non-project resource (e.g. epics), then project is not set, and there is mismatch in generated issue references. This patch enables issuable reference state check also from group scope. Closes gitlab-ee#4683 Related to #30916 --- changelogs/unreleased/jprovazn-issueref.yml | 6 ++++++ lib/banzai/filter/issuable_state_filter.rb | 10 +++++++++- spec/lib/banzai/filter/issuable_state_filter_spec.rb | 8 ++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/jprovazn-issueref.yml diff --git a/changelogs/unreleased/jprovazn-issueref.yml b/changelogs/unreleased/jprovazn-issueref.yml new file mode 100644 index 00000000000..ee19cac7b19 --- /dev/null +++ b/changelogs/unreleased/jprovazn-issueref.yml @@ -0,0 +1,6 @@ +--- +title: Display state indicator for issuable references in non-project scope (e.g. + when referencing issuables from group scope). +merge_request: +author: +type: fixed diff --git a/lib/banzai/filter/issuable_state_filter.rb b/lib/banzai/filter/issuable_state_filter.rb index 77299abe324..8f541dcfdb2 100644 --- a/lib/banzai/filter/issuable_state_filter.rb +++ b/lib/banzai/filter/issuable_state_filter.rb @@ -17,7 +17,7 @@ module Banzai issuables.each do |node, issuable| next if !can_read_cross_project? && issuable.project != project - if VISIBLE_STATES.include?(issuable.state) && node.inner_html == issuable.reference_link_text(project) + if VISIBLE_STATES.include?(issuable.state) && issuable_reference?(node.inner_html, issuable) node.content += " (#{issuable.state})" end end @@ -27,6 +27,10 @@ module Banzai private + def issuable_reference?(text, issuable) + text == issuable.reference_link_text(project || group) + end + def can_read_cross_project? Ability.allowed?(current_user, :read_cross_project) end @@ -38,6 +42,10 @@ module Banzai def project context[:project] end + + def group + context[:group] + end end end end diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb index 17347768a49..a5373517ac8 100644 --- a/spec/lib/banzai/filter/issuable_state_filter_spec.rb +++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb @@ -8,6 +8,7 @@ describe Banzai::Filter::IssuableStateFilter do let(:context) { { current_user: user, issuable_state_filter_enabled: true } } let(:closed_issue) { create_issue(:closed) } let(:project) { create(:project, :public) } + let(:group) { create(:group) } let(:other_project) { create(:project, :public) } def create_link(text, data) @@ -77,6 +78,13 @@ describe Banzai::Filter::IssuableStateFilter do expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)} (closed)") end + it 'handles references from group scopes' do + link = create_link(closed_issue.to_reference(other_project), issue: closed_issue.id, reference_type: 'issue') + doc = filter(link, context.merge(project: nil, group: group)) + + expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)} (closed)") + end + it 'skips cross project references if the user cannot read cross project' do expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false } link = create_link(closed_issue.to_reference(other_project), issue: closed_issue.id, reference_type: 'issue') -- cgit v1.2.1 From 9b1f5b68c71a7a20a8fac7ae7eccc7b2195257bc Mon Sep 17 00:00:00 2001 From: Tim Zallmann Date: Fri, 23 Mar 2018 12:26:57 +0100 Subject: Update Prettier Print WIdth to 100 --- .prettierrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierrc b/.prettierrc index a20502b7f06..5e2863a11f6 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { + "printWidth": 100, "singleQuote": true, "trailingComma": "all" } -- cgit v1.2.1 From 0189ee979ca6b17f6fafbffb53b0c1608f1adae3 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Fri, 23 Mar 2018 13:01:40 +0100 Subject: Show gitaly address instead of disk path --- app/helpers/application_settings_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index c8dfa140529..b3b080e6dcf 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -96,7 +96,7 @@ module ApplicationSettingsHelper def repository_storages_options_for_select(selected) options = Gitlab.config.repositories.storages.map do |name, storage| - ["#{name} - #{storage.legacy_disk_path}", name] + ["#{name} - #{storage['gitaly_address']}", name] end options_for_select(options, selected) -- cgit v1.2.1 From e10bd302a5f1739c9f43b12798b8c29d19690a64 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 23 Mar 2018 12:27:20 +0000 Subject: Disable cursor for review mode in the IDE Also disables occurrences highlights & the line highlight for the review mode. Closes #44307 --- app/assets/javascripts/ide/lib/editor.js | 4 ++++ app/assets/stylesheets/pages/repo.scss | 4 ++++ spec/javascripts/ide/lib/editor_spec.js | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 38de2fe2b27..887dd7e39b1 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -65,6 +65,10 @@ export default class Editor { (this.instance = this.monaco.editor.createDiffEditor(domElement, { ...defaultEditorOptions, readOnly: true, + quickSuggestions: false, + occurrencesHighlight: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, })), ); diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 7a8fbfc517d..57b995adb64 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -290,6 +290,10 @@ .margin-view-overlays .delete-sign { opacity: 0.4; } + + .cursors-layer { + display: none; + } } } diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js index d6df35c90e8..3c48d94d17a 100644 --- a/spec/javascripts/ide/lib/editor_spec.js +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -74,6 +74,10 @@ describe('Multi-file editor library', () => { }, readOnly: true, scrollBeyondLastLine: false, + quickSuggestions: false, + occurrencesHighlight: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, }, ); }); -- cgit v1.2.1 From 2fe8329c421a3248b0cea06a0ce566cd370e6b2d Mon Sep 17 00:00:00 2001 From: Bertrand Jamin Date: Fri, 23 Mar 2018 13:20:07 +0000 Subject: Update the menu information to access "Schedules" page --- doc/user/project/pipelines/schedules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md index 34809a2826f..a13b1b4561c 100644 --- a/doc/user/project/pipelines/schedules.md +++ b/doc/user/project/pipelines/schedules.md @@ -12,7 +12,7 @@ month on the 22nd for a certain branch. In order to schedule a pipeline: -1. Navigate to your project's **Pipelines ➔ Schedules** and click the +1. Navigate to your project's **CI / CD ➔ Schedules** and click the **New Schedule** button. 1. Fill in the form 1. Hit **Save pipeline schedule** for the changes to take effect. -- cgit v1.2.1 From bfdb3f20efa35e1a4451e159f3fd8be81b124532 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Fri, 23 Mar 2018 14:40:58 +0100 Subject: Use gitlab-shell 7.1.1 --- GITLAB_SHELL_VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index a3fcc7121bb..21c8c7b46b8 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -7.1.0 +7.1.1 -- cgit v1.2.1 From 783868e9faa1e3dff27765f8244dc59898a2f7b2 Mon Sep 17 00:00:00 2001 From: Andreas Brandl Date: Fri, 23 Mar 2018 13:58:46 +0100 Subject: Remove N+1 query for Noteable association. For each event with `Event#target_type` of "Note", we had a query to load the associated instance `noteable`. For example, if `Note` was related to an issue, we'd load each `Issue` with its own query (N+1 problem). Closes #43150. --- app/models/event.rb | 10 +++++----- .../unreleased/ab-43150-users-controller-show-query-limit.yml | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml diff --git a/app/models/event.rb b/app/models/event.rb index 17a198d52c7..3805f6cf857 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -52,12 +52,12 @@ class Event < ActiveRecord::Base belongs_to :target, -> { # If the association for "target" defines an "author" association we want to # eager-load this so Banzai & friends don't end up performing N+1 queries to - # get the authors of notes, issues, etc. - if reflections['events'].active_record.reflect_on_association(:author) - includes(:author) - else - self + # get the authors of notes, issues, etc. (likewise for "noteable"). + incs = %i(author noteable).select do |a| + reflections['events'].active_record.reflect_on_association(a) end + + incs.reduce(self) { |obj, a| obj.includes(a) } }, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations has_one :push_event_payload diff --git a/changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml b/changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml new file mode 100644 index 00000000000..502c1176d2d --- /dev/null +++ b/changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml @@ -0,0 +1,5 @@ +--- +title: Remove N+1 query for Noteable association. +merge_request: 17956 +author: +type: performance -- cgit v1.2.1 From 95f2826e1aa23809412f32263c399c67f005df16 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 23 Mar 2018 15:43:17 +0100 Subject: Remove outdates TODOs from pipeline class --- app/models/ci/pipeline.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 643a8324c7d..156ad194d2e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -478,14 +478,10 @@ module Ci end end - # TODO specs - # def protected_ref? strong_memoize(:protected_ref) { project.protected_for?(ref) } end - # TODO specs - # def legacy_trigger strong_memoize(:legacy_trigger) { trigger_requests.first } end -- cgit v1.2.1 From 058dd193603910efb12cba84339474e3e8778c31 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Fri, 23 Mar 2018 15:51:28 +0000 Subject: Add ?, !, and : to trailing puncutation excluded from auto-linking --- ...44587-autolinking-includes-trailing-exclamation-marks.yml | 5 +++++ lib/banzai/filter/autolink_filter.rb | 11 ++++++----- spec/lib/banzai/filter/autolink_filter_spec.rb | 12 ++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml diff --git a/changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml b/changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml new file mode 100644 index 00000000000..636fde601ee --- /dev/null +++ b/changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml @@ -0,0 +1,5 @@ +--- +title: Don't capture trailing punctuation when autolinking +merge_request: 17965 +author: +type: fixed diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index 75b64ae9af2..ce401c1c31c 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -21,12 +21,13 @@ module Banzai # # See http://en.wikipedia.org/wiki/URI_scheme # - # The negative lookbehind ensures that users can paste a URL followed by a - # period or comma for punctuation without those characters being included - # in the generated link. + # The negative lookbehind ensures that users can paste a URL followed by + # punctuation without those characters being included in the generated + # link. It matches the behaviour of Rinku 2.0.1: + # https://github.com/vmg/rinku/blob/v2.0.1/ext/rinku/autolink.c#L65 # - # Rubular: http://rubular.com/r/JzPhi6DCZp - LINK_PATTERN = %r{([a-z][a-z0-9\+\.-]+://[^\s>]+)(?]+)(? Date: Fri, 23 Mar 2018 16:26:17 +0000 Subject: Fix a spec that will break in EE --- spec/features/projects/pages_spec.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index 020738ae865..bdd49f731c7 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -244,10 +244,11 @@ feature 'Pages' do end context 'setting could not be updated' do + let(:service) { instance_double('Projects::UpdateService') } + before do - allow_any_instance_of(Projects::UpdateService) - .to receive(:execute) - .and_return(status: :error) + allow(Projects::UpdateService).to receive(:new).and_return(service) + allow(service).to receive(:execute).and_return(status: :error) end scenario 'tries to change the setting' do -- cgit v1.2.1 From 3c54794445ea8e3bd7c3356450b86fc4c6ba12cd Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 23 Mar 2018 11:42:34 -0500 Subject: update prettier config to differentiate node scripts from babel compiled modules --- .prettierignore | 3 --- .prettierrc | 10 +++++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.prettierignore b/.prettierignore index 62fe6a45b95..a4a5e8ada89 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,3 @@ -/config/ /node_modules/ /public/ /vendor/ -karma.config.js -webpack.config.js diff --git a/.prettierrc b/.prettierrc index 5e2863a11f6..748cfcae65f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,13 @@ { "printWidth": 100, "singleQuote": true, - "trailingComma": "all" + "trailingComma": "es5", + "overrides": [ + { + "files": ["app/assets/javascripts/*", "spec/javascripts/*"], + "options": { + "trailingComma": "all" + } + } + ] } -- cgit v1.2.1 From 5b6931b8e70dbd1c67233cb5f64a553e3ce7e465 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 23 Mar 2018 11:44:10 -0500 Subject: ignore generated locale files with prettier --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index a4a5e8ada89..dbd8382ea15 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +/app/assets/javascripts/locale/**/app.js /node_modules/ /public/ /vendor/ -- cgit v1.2.1 From 433a6b02d312bc23512e5f5a52845e830bf357ae Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Fri, 23 Mar 2018 17:29:55 +0100 Subject: Move GitLab.com settings under main docs --- doc/user/gitlab_com/index.md | 262 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 doc/user/gitlab_com/index.md diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md new file mode 100644 index 00000000000..83eb7a225b2 --- /dev/null +++ b/doc/user/gitlab_com/index.md @@ -0,0 +1,262 @@ +# GitLab.com settings + +In this page you will find information about the settings that are used on +[GitLab.com](https://about.gitlab.com/pricing). + +## SSH host keys fingerprints + +Below are the fingerprints for GitLab.com's SSH host keys. + +| Algorithm | MD5 | SHA256 | +| --------- | --- | ------- | +| DSA | `7a:47:81:3a:ee:89:89:64:33:ca:44:52:3d:30:d4:87` | `p8vZBUOR0XQz6sYiaWSMLmh0t9i8srqYKool/Xfdfqw` | +| ECDSA | `f1:d0:fb:46:73:7a:70:92:5a:ab:5d:ef:43:e2:1c:35` | `HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw` | +| ED25519 | `2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16` | `eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8` | +| RSA | `b6:03:0e:39:97:9e:d0:e7:24:ce:a3:77:3e:01:42:09` | `ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ` | + +## Mail configuration + +GitLab.com sends emails from the `mg.gitlab.com` domain via [Mailgun] and has +its own dedicated IP address (`198.61.254.240`). + +## Alternative SSH port + +GitLab.com can be reached via a [different SSH port][altssh] for `git+ssh`. + +| Setting | Value | +| --------- | ------------------- | +| `Hostname` | `altssh.gitlab.com` | +| `Port` | `443` | + +An example `~/.ssh/config` is the following: + +``` +Host gitlab.com + Hostname altssh.gitlab.com + User git + Port 443 + PreferredAuthentications publickey + IdentityFile ~/.ssh/gitlab +``` + +## GitLab Pages + +Below are the settings for [GitLab Pages]. + +| Setting | GitLab.com | Default | +| ----------------------- | ---------------- | ------------- | +| Domain name | `gitlab.io` | - | +| IP address | `52.167.214.135` | - | +| Custom domains support | yes | no | +| TLS certificates support| yes | no | + +The maximum size of your Pages site is regulated by the artifacts maximum size +which is part of [GitLab CI](#gitlab-ci). + +## GitLab CI/CD + +Below are the current settings regarding [GitLab CI/CD](../../ci/README.md). + +| Setting | GitLab.com | Default | +| ----------- | ----------------- | ------------- | +| Artifacts maximum size | 1G | 100M | + +## Shared Runners + +Shared Runners on GitLab.com run in [autoscale mode] and powered by +DigitalOcean. Autoscaling means reduced waiting times to spin up builds, +and isolated VMs for each project, thus maximizing security. + +They're free to use for public open source projects and limited to 2000 CI +minutes per month per group for private projects. Read about all +[GitLab.com plans](https://about.gitlab.com/pricing/). + +All your builds run on 2GB (RAM) ephemeral instances, with CoreOS and the latest +Docker Engine installed. The default region of the VMs is NYC. + +Below are the shared Runners settings. + +| Setting | GitLab.com | Default | +| ----------- | ----------------- | ---------- | +| [GitLab Runner] | [Runner versions dashboard][ci_version_dashboard] | - | +| Executor | `docker+machine` | - | +| Default Docker image | `ruby:2.1` | - | +| `privileged` (run [Docker in Docker]) | `true` | `false` | + +[ci_version_dashboard]: https://monitor.gitlab.net/dashboard/db/ci?refresh=5m&orgId=1&panelId=12&fullscreen&from=now-1h&to=now&var-runner_type=All&var-cache_server=All&var-gl_monitor_fqdn=postgres-01.db.prd.gitlab.com&var-has_minutes=yes&var-hanging_droplets_cleaner=All&var-droplet_zero_machines_cleaner=All&var-runner_job_failure_reason=All&theme=light + +### `config.toml` + +The full contents of our `config.toml` are: + +```toml +[[runners]] + name = "docker-auto-scale" + limit = X + request_concurrency = X + url = "https://gitlab.com/ci" + token = "SHARED_RUNNER_TOKEN" + executor = "docker+machine" + environment = [ + "DOCKER_DRIVER=overlay2" + ] + [runners.docker] + image = "ruby:2.1" + privileged = true + [runners.machine] + IdleCount = 40 + IdleTime = 1800 + MaxBuilds = 1 + MachineDriver = "digitalocean" + MachineName = "machine-%s-digital-ocean-2gb" + MachineOptions = [ + "digitalocean-image=coreos-stable", + "digitalocean-ssh-user=core", + "digitalocean-access-token=DIGITAL_OCEAN_ACCESS_TOKEN", + "digitalocean-region=nyc1", + "digitalocean-size=2gb", + "digitalocean-private-networking", + "digitalocean-userdata=/etc/gitlab-runner/cloudinit.sh", + "engine-registry-mirror=http://IP_TO_OUR_REGISTRY_MIRROR" + ] + [runners.cache] + Type = "s3" + ServerAddress = "IP_TO_OUR_CACHE_SERVER" + AccessKey = "ACCESS_KEY" + SecretKey = "ACCESS_SECRET_KEY" + BucketName = "runner" + Shared = true +``` + +## Sidekiq + +GitLab.com runs [Sidekiq][sidekiq] with arguments `--timeout=4 --concurrency=4` +and the following environment variables: + +| Setting | GitLab.com | Default | +|-------- |----------- |-------- | +| `SIDEKIQ_MEMORY_KILLER_MAX_RSS` | `1000000` | `1000000` | +| `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_SIGNAL` | `SIGKILL` | - | +| `SIDEKIQ_LOG_ARGUMENTS` | `1` | - | + +## Cron jobs + +Periodically executed jobs by Sidekiq, to self-heal Gitlab, do external +synchronizations, run scheduled pipelines, etc.: + +| Setting | GitLab.com | Default | +|-------- |------------- |------------- | +| `pipeline_schedule_worker` | `19 * * * *` | `19 * * * *` | + +## PostgreSQL + +GitLab.com being a fairly large installation of GitLab means we have changed +various PostgreSQL settings to better suit our needs. For example, we use +streaming replication and servers in hot-standby mode to balance queries across +different database servers. + +The list of GitLab.com specific settings (and their defaults) is as follows: + +| Setting | GitLab.com | Default | +|:------------------------------------|:--------------------------------------------------------------------|:--------------------------------------| +| archive_command | `/usr/bin/envdir /etc/wal-e.d/env /opt/wal-e/bin/wal-e wal-push %p` | empty | +| archive_mode | on | off | +| autovacuum_analyze_scale_factor | 0.01 | 0.01 | +| autovacuum_max_workers | 6 | 3 | +| autovacuum_vacuum_cost_limit | 1000 | -1 | +| autovacuum_vacuum_scale_factor | 0.01 | 0.02 | +| checkpoint_completion_target | 0.7 | 0.9 | +| checkpoint_segments | 32 | 10 | +| effective_cache_size | 338688MB | Based on how much memory is available | +| hot_standby | on | off | +| hot_standby_feedback | on | off | +| log_autovacuum_min_duration | 0 | -1 | +| log_checkpoints | on | off | +| log_line_prefix | `%t [%p]: [%l-1] ` | empty | +| log_min_duration_statement | 1000 | -1 | +| log_temp_files | 0 | -1 | +| maintenance_work_mem | 2048MB | 16 MB | +| max_replication_slots | 5 | 0 | +| max_wal_senders | 32 | 0 | +| max_wal_size | 5GB | 1GB | +| shared_buffers | 112896MB | Based on how much memory is available | +| shared_preload_libraries | pg_stat_statements | empty | +| shmall | 30146560 | Based on the server's capabilities | +| shmmax | 123480309760 | Based on the server's capabilities | +| wal_buffers | 16MB | -1 | +| wal_keep_segments | 512 | 10 | +| wal_level | replica | minimal | +| statement_timeout | 15s | 60s | +| idle_in_transaction_session_timeout | 60s | 60s | + +Some of these settings are in the process being adjusted. For example, the value +for `shared_buffers` is quite high and as such we are looking into adjusting it. +More information on this particular change can be found at +. An up to date list +of proposed changes can be found at +. + +## Unicorn + +GitLab.com adjusts the memory limits for the [unicorn-worker-killer][unicorn-worker-killer] gem. + +Base default: +* `memory_limit_min` = 750MiB +* `memory_limit_max` = 1024MiB + +Web front-ends: +* `memory_limit_min` = 1024MiB +* `memory_limit_max` = 1280MiB + +## GitLab.com at scale + +In addition to the GitLab Enterprise Edition Omnibus install, GitLab.com uses +the following applications and settings to achieve scale. All settings are +located publicly available [chef cookbooks](https://gitlab.com/gitlab-cookbooks). + +### ELK + +We use Elasticsearch, logstash, and Kibana for part of our monitoring solution: + +- [gitlab-cookbooks / gitlab-elk · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-elk) +- [gitlab-cookbooks / gitlab_elasticsearch · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab_elasticsearch) + +### Prometheus + +Prometheus complete our monitoring stack: + +- [gitlab-cookbooks / gitlab-prometheus · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-prometheus) + +### Grafana + +For the visualization of monitoring data: + +- [gitlab-cookbooks / gitlab-grafana · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-grafana) + +### Sentry + +Open source error tracking: + +- [gitlab-cookbooks / gitlab-sentry · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-sentry) + +### Consul + +Service discovery: + +- [gitlab-cookbooks / gitlab_consul · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab_consul) + +### Haproxy + +High Performance TCP/HTTP Load Balancer: + +- [gitlab-cookbooks / gitlab-haproxy · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-haproxy) + +[autoscale mode]: https://docs.gitlab.com/runner/configuration/autoscale.html "How Autoscale works" +[runners-post]: https://about.gitlab.com/2016/04/05/shared-runners/ "Shared Runners on GitLab.com" +[GitLab Runner]: https://gitlab.com/gitlab-org/gitlab-runner +[altssh]: https://about.gitlab.com/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/ "GitLab.com now supports an alternate git+ssh port" +[GitLab Pages]: https://about.gitlab.com/features/pages "GitLab Pages" +[docker in docker]: https://hub.docker.com/_/docker/ "Docker in Docker at DockerHub" +[mailgun]: https://www.mailgun.com/ "Mailgun website" +[sidekiq]: http://sidekiq.org/ "Sidekiq website" +[unicorn-worker-killer]: https://rubygems.org/gems/unicorn-worker-killer "unicorn-worker-killer" -- cgit v1.2.1 From 9238c5d5ca22d2fddc0c78a34dc063c8d3abb79e Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 23 Mar 2018 11:52:54 -0500 Subject: prettify all node scripts --- .babelrc | 26 ++++----- config/karma.config.js | 8 +-- config/webpack.config.js | 95 +++++++++++++++++-------------- scripts/frontend/frontend_script_utils.js | 7 +-- scripts/frontend/prettier.js | 17 ++---- 5 files changed, 74 insertions(+), 79 deletions(-) diff --git a/.babelrc b/.babelrc index b93bef72de1..8cf07b73420 100644 --- a/.babelrc +++ b/.babelrc @@ -1,20 +1,20 @@ { - "presets": [ - ["latest", { "es2015": { "modules": false } }], - "stage-2" - ], + "presets": [["latest", { "es2015": { "modules": false } }], "stage-2"], "env": { "coverage": { "plugins": [ - ["istanbul", { - "exclude": [ - "spec/javascripts/**/*", - "app/assets/javascripts/locale/**/app.js" - ] - }], - ["transform-define", { - "process.env.BABEL_ENV": "coverage" - }] + [ + "istanbul", + { + "exclude": ["spec/javascripts/**/*", "app/assets/javascripts/locale/**/app.js"] + } + ], + [ + "transform-define", + { + "process.env.BABEL_ENV": "coverage" + } + ] ] } } diff --git a/config/karma.config.js b/config/karma.config.js index 3d95e1622b2..7ede745b591 100644 --- a/config/karma.config.js +++ b/config/karma.config.js @@ -5,7 +5,7 @@ var ROOT_PATH = path.resolve(__dirname, '..'); // remove problematic plugins if (webpackConfig.plugins) { - webpackConfig.plugins = webpackConfig.plugins.filter(function (plugin) { + webpackConfig.plugins = webpackConfig.plugins.filter(function(plugin) { return !( plugin instanceof webpack.optimize.CommonsChunkPlugin || plugin instanceof webpack.optimize.ModuleConcatenationPlugin || @@ -24,7 +24,7 @@ module.exports = function(config) { var karmaConfig = { basePath: ROOT_PATH, - browsers: ['ChromeHeadlessCustom'], + browsers: ['ChromeHeadlessCustom'], customLaunchers: { ChromeHeadlessCustom: { base: 'ChromeHeadless', @@ -34,7 +34,7 @@ module.exports = function(config) { // escalated kernel privileges (e.g. docker run --cap-add=CAP_SYS_ADMIN) '--no-sandbox', ], - } + }, }, frameworks: ['jasmine'], files: [ @@ -55,7 +55,7 @@ module.exports = function(config) { reports: ['html', 'text-summary'], dir: 'coverage-javascript/', subdir: '.', - fixWebpackSourcePaths: true + fixWebpackSourcePaths: true, }; karmaConfig.browserNoActivityTimeout = 60000; // 60 seconds } diff --git a/config/webpack.config.js b/config/webpack.config.js index 42fe4b345e1..b74d9dde494 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -1,5 +1,3 @@ -'use strict'; - const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); @@ -27,10 +25,10 @@ let watchAutoEntries = []; function generateEntries() { // generate automatic entry points const autoEntries = {}; - const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') }); - watchAutoEntries = [ - path.join(ROOT_PATH, 'app/assets/javascripts/pages/'), - ]; + const pageEntries = glob.sync('pages/**/index.js', { + cwd: path.join(ROOT_PATH, 'app/assets/javascripts'), + }); + watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')]; function generateAutoEntries(path, prefix = '.') { const chunkPath = path.replace(/\/index\.js$/, ''); @@ -38,16 +36,16 @@ function generateEntries() { autoEntries[chunkName] = `${prefix}/${path}`; } - pageEntries.forEach(( path ) => generateAutoEntries(path)); + pageEntries.forEach(path => generateAutoEntries(path)); autoEntriesCount = Object.keys(autoEntries).length; const manualEntries = { - common: './commons/index.js', - main: './main.js', - raven: './raven/index.js', - webpack_runtime: './webpack.js', - ide: './ide/index.js', + common: './commons/index.js', + main: './main.js', + raven: './raven/index.js', + webpack_runtime: './webpack.js', + ide: './ide/index.js', }; return Object.assign(manualEntries, autoEntries); @@ -91,8 +89,8 @@ const config = { { loader: 'worker-loader', options: { - inline: true - } + inline: true, + }, }, { loader: 'babel-loader' }, ], @@ -103,7 +101,7 @@ const config = { loader: 'file-loader', options: { name: '[name].[hash].[ext]', - } + }, }, { test: /katex.css$/, @@ -113,8 +111,8 @@ const config = { { loader: 'css-loader', options: { - name: '[name].[hash].[ext]' - } + name: '[name].[hash].[ext]', + }, }, ], }, @@ -124,7 +122,7 @@ const config = { loader: 'file-loader', options: { name: '[name].[hash].[ext]', - } + }, }, { test: /monaco-editor\/\w+\/vs\/loader\.js$/, @@ -132,7 +130,7 @@ const config = { { loader: 'exports-loader', options: 'l.global' }, { loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' }, ], - } + }, ], noParse: [/monaco-editor\/\w+\/vs\//], @@ -150,10 +148,10 @@ const config = { source: false, chunks: false, modules: false, - assets: true + assets: true, }); return JSON.stringify(stats, null, 2); - } + }, }), // prevent pikaday from including moment.js @@ -170,7 +168,7 @@ const config = { new NameAllModulesPlugin(), // assign deterministic chunk ids - new webpack.NamedChunksPlugin((chunk) => { + new webpack.NamedChunksPlugin(chunk => { if (chunk.name) { return chunk.name; } @@ -187,9 +185,12 @@ const config = { const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages'); if (m.resource.indexOf(pagesBase) === 0) { - moduleNames.push(path.relative(pagesBase, m.resource) - .replace(/\/index\.[a-z]+$/, '') - .replace(/\//g, '__')); + moduleNames.push( + path + .relative(pagesBase, m.resource) + .replace(/\/index\.[a-z]+$/, '') + .replace(/\//g, '__') + ); } else { moduleNames.push(path.relative(m.context, m.resource)); } @@ -197,7 +198,8 @@ const config = { chunk.forEachModule(collectModuleNames); - const hash = crypto.createHash('sha256') + const hash = crypto + .createHash('sha256') .update(moduleNames.join('_')) .digest('hex'); @@ -212,7 +214,10 @@ const config = { // copy pre-compiled vendor libraries verbatim new CopyWebpackPlugin([ { - from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`), + from: path.join( + ROOT_PATH, + `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs` + ), to: 'monaco-editor/vs', transform: function(content, path) { if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) { @@ -225,23 +230,23 @@ const config = { ); } return content; - } - } + }, + }, ]), ], resolve: { extensions: ['.js'], alias: { - '~': path.join(ROOT_PATH, 'app/assets/javascripts'), - 'emojis': path.join(ROOT_PATH, 'fixtures/emojis'), - 'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'), - 'icons': path.join(ROOT_PATH, 'app/views/shared/icons'), - 'images': path.join(ROOT_PATH, 'app/assets/images'), - 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), - 'vue$': 'vue/dist/vue.esm.js', - 'spec': path.join(ROOT_PATH, 'spec/javascripts'), - } + '~': path.join(ROOT_PATH, 'app/assets/javascripts'), + emojis: path.join(ROOT_PATH, 'fixtures/emojis'), + empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'), + icons: path.join(ROOT_PATH, 'app/views/shared/icons'), + images: path.join(ROOT_PATH, 'app/assets/images'), + vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'), + vue$: 'vue/dist/vue.esm.js', + spec: path.join(ROOT_PATH, 'spec/javascripts'), + }, }, // sqljs requires fs @@ -256,14 +261,14 @@ if (IS_PRODUCTION) { new webpack.NoEmitOnErrorsPlugin(), new webpack.LoaderOptionsPlugin({ minimize: true, - debug: false + debug: false, }), new webpack.optimize.ModuleConcatenationPlugin(), new webpack.optimize.UglifyJsPlugin({ - sourceMap: true + sourceMap: true, }), new webpack.DefinePlugin({ - 'process.env': { NODE_ENV: JSON.stringify('production') } + 'process.env': { NODE_ENV: JSON.stringify('production') }, }) ); @@ -282,7 +287,7 @@ if (IS_DEV_SERVER) { headers: { 'Access-Control-Allow-Origin': '*' }, stats: 'errors-only', hot: DEV_SERVER_LIVERELOAD, - inline: DEV_SERVER_LIVERELOAD + inline: DEV_SERVER_LIVERELOAD, }; config.plugins.push( // watch node_modules for changes if we encounter a missing module compile error @@ -298,10 +303,12 @@ if (IS_DEV_SERVER) { ]; // report our auto-generated bundle count - console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`); + console.log( + `${autoEntriesCount} entries from '/pages' automatically added to webpack output.` + ); callback(); - }) + }); }, } ); diff --git a/scripts/frontend/frontend_script_utils.js b/scripts/frontend/frontend_script_utils.js index 2c06747255c..bbc1203262d 100644 --- a/scripts/frontend/frontend_script_utils.js +++ b/scripts/frontend/frontend_script_utils.js @@ -18,12 +18,7 @@ const execGitCmd = args => module.exports = { getStagedFiles: fileExtensionFilter => { - const gitOptions = [ - 'diff', - '--name-only', - '--cached', - '--diff-filter=ACMRTUB', - ]; + const gitOptions = ['diff', '--name-only', '--cached', '--diff-filter=ACMRTUB']; if (fileExtensionFilter) gitOptions.push(...fileExtensionFilter); return execGitCmd(gitOptions); }, diff --git a/scripts/frontend/prettier.js b/scripts/frontend/prettier.js index 863572bf64d..94506eed619 100644 --- a/scripts/frontend/prettier.js +++ b/scripts/frontend/prettier.js @@ -22,9 +22,7 @@ const availableExtensions = Object.keys(config.parsers); console.log(`Loading ${allFiles ? 'All' : 'Staged'} Files ...`); -const stagedFiles = allFiles - ? null - : getStagedFiles(availableExtensions.map(ext => `*.${ext}`)); +const stagedFiles = allFiles ? null : getStagedFiles(availableExtensions.map(ext => `*.${ext}`)); if (stagedFiles) { if (!stagedFiles.length || (stagedFiles.length === 1 && !stagedFiles[0])) { @@ -41,15 +39,10 @@ let files; if (allFiles) { const ignore = config.ignore; const patterns = config.patterns; - const globPattern = - patterns.length > 1 ? `{${patterns.join(',')}}` : `${patterns.join(',')}`; - files = glob - .sync(globPattern, { ignore }) - .filter(f => allFiles || stagedFiles.includes(f)); + const globPattern = patterns.length > 1 ? `{${patterns.join(',')}}` : `${patterns.join(',')}`; + files = glob.sync(globPattern, { ignore }).filter(f => allFiles || stagedFiles.includes(f)); } else { - files = stagedFiles.filter(f => - availableExtensions.includes(f.split('.').pop()), - ); + files = stagedFiles.filter(f => availableExtensions.includes(f.split('.').pop())); } if (!files.length) { @@ -81,7 +74,7 @@ prettier } else if (!prettier.check(input, options)) { if (!didWarn) { console.log( - '\n===============================\nGitLab uses Prettier to format all JavaScript code.\nPlease format each file listed below or run "yarn prettier-staged-save"\n===============================\n', + '\n===============================\nGitLab uses Prettier to format all JavaScript code.\nPlease format each file listed below or run "yarn prettier-staged-save"\n===============================\n' ); didWarn = true; } -- cgit v1.2.1 From 569b06cf20b455c606171667d73003aff5520c0e Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 23 Mar 2018 11:53:12 -0500 Subject: update eslintignore for node scripts --- .eslintignore | 5 +++-- scripts/frontend/frontend_script_utils.js | 1 - scripts/frontend/prettier.js | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.eslintignore b/.eslintignore index 1623b996213..33a8186fade 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,11 +1,12 @@ +/app/assets/javascripts/locale/**/app.js +/config/ /builds/ /coverage/ /coverage-javascript/ /node_modules/ /public/ +/scripts/ /tmp/ /vendor/ karma.config.js webpack.config.js -svg.config.js -/app/assets/javascripts/locale/**/app.js diff --git a/scripts/frontend/frontend_script_utils.js b/scripts/frontend/frontend_script_utils.js index bbc1203262d..e42b912d359 100644 --- a/scripts/frontend/frontend_script_utils.js +++ b/scripts/frontend/frontend_script_utils.js @@ -1,4 +1,3 @@ -/* eslint import/no-commonjs: "off" */ const execFileSync = require('child_process').execFileSync; const exec = (command, args) => { diff --git a/scripts/frontend/prettier.js b/scripts/frontend/prettier.js index 94506eed619..2708340b48e 100644 --- a/scripts/frontend/prettier.js +++ b/scripts/frontend/prettier.js @@ -1,4 +1,3 @@ -/* eslint import/no-commonjs: "off", import/no-extraneous-dependencies: "off", no-console: "off" */ const glob = require('glob'); const prettier = require('prettier'); const fs = require('fs'); -- cgit v1.2.1 From 3cda5c6a7e47b270d07ef3556bdf017e7ec8494b Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Wed, 14 Mar 2018 13:45:18 -0600 Subject: Add i18n support for the mrWidget prometheus memory widget --- .../components/memory_usage.vue | 30 +++++--- ...ort-for-the-prometheus-merge-request-widget.yml | 5 ++ .../components/mr_widget_memory_usage_spec.js | 89 ++++++++++++++-------- 3 files changed, 83 insertions(+), 41 deletions(-) create mode 100644 changelogs/unreleased/44218-add-internationalization-support-for-the-prometheus-merge-request-widget.yml diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue index a16f9055a6d..95c8b0a4c55 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue @@ -1,4 +1,5 @@