diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2018-03-19 13:05:08 +0300 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2018-03-19 13:05:08 +0300 |
commit | 2a5f9709614a33143cb0f47a2602cf161ba4b2cd (patch) | |
tree | e7b5d209853ac608a4aeb67bb4a7161a2f64f143 | |
parent | 024a0b837b6cb93f6ba5171541eadfc64e3897d6 (diff) | |
parent | e1739e47c5664c93c66dd58ded59f9d79cd8a426 (diff) |
[ci skip] Merge branch 'master' into 42568-pipeline-empty-state
* master: (156 commits)
Make issue boards height calculation clearer and remove magic numbers
Rename `package-qa` in docs
Copyedit CI services docs
Fix dropzone project show
Prettify notes.
Fix "A copy of Gitlab::Metrics::Methods" have been removed error
Add Jupyter Notebook docs
Fixed profile notifications not being editable
Rename manual job to `package-and-qa`
Update vendored gitlab-ci.yml template for auto-devops
Docs: refactor doc general guidelines and style guidelines
Use Gitaly 0.91.0
Add version available info to integrity check rake task docs
Adds the option to override project description on export via API and fixes the project description not being imported
Extract constant for LfsPointerFile::VERSION_LINE
Setup Faraday middleware before adapter
Revert "Merge branch 'update-httparty' into 'master'"
Update Code Quality example documentation
Fix timeouts loading /admin/projects page
OpenShift install docs: Recommend "add-scc-to-user" over "edit scc"
...
259 files changed, 6069 insertions, 3539 deletions
diff --git a/.eslintrc b/.eslintrc index a86f8a841c3..3f187db0c07 100644 --- a/.eslintrc +++ b/.eslintrc @@ -35,15 +35,22 @@ "import/no-commonjs": "error", "no-multiple-empty-lines": ["error", { "max": 1 }], "promise/catch-or-return": "error", - "no-underscore-dangle": ["error", { "allow": ["__", "_links"]}], - "vue/html-self-closing": ["error", { - "html": { - "void": "always", - "normal": "never", - "component": "always" - }, - "svg": "always", - "math": "always" - }] + "no-underscore-dangle": ["error", { "allow": ["__", "_links"] }], + "no-mixed-operators": 0, + "space-before-function-paren": 0, + "curly": 0, + "arrow-parens": 0, + "vue/html-self-closing": [ + "error", + { + "html": { + "void": "always", + "normal": "never", + "component": "always" + }, + "svg": "always", + "math": "always" + } + ] } } diff --git a/.gitignore b/.gitignore index 90c6bd99d30..447fb71bd64 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ eslint-report.html /.gitlab_shell_secret .idea +/.vscode/* /.rbenv-version .rbx/ /.ruby-gemset diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5556bf5bc0b..724e37141d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -257,7 +257,7 @@ stages: ## # Trigger a package build in omnibus-gitlab repository # -package-qa: +package-and-qa: <<: *dedicated-runner image: ruby:2.4-alpine before_script: [] diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000000..a20502b7f06 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 630aef6751f..8c64e68967e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.5.5 (2018-03-15) + +### Fixed (3 changes) + +- Fix missing uploads after group transfer. !17658 +- Fix code and wiki search results when filename is non-ASCII. +- Remove double caching of Repository#empty?. + +### Performance (2 changes) + +- Adding missing indexes on taggings table. +- Add index on section_name_id on ci_build_trace_sections table. + + ## 10.5.4 (2018-03-08) ### Fixed (11 changes) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 5aee1345c52..8f63f4f9a10 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.89.0 +0.91.0 diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index faef31a4357..39e898a4f95 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 @@ -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.7.0' +gem 'omniauth-saml', '~> 1.10.0' gem 'omniauth-shibboleth', '~> 1.2.0' gem 'omniauth-twitter', '~> 1.2.0' gem 'omniauth_crowd', '~> 2.2.0' @@ -104,7 +104,7 @@ gem 'carrierwave', '~> 1.2' gem 'dropzonejs-rails', '~> 0.7.1' # for backups -gem 'fog-aws', '~> 1.4' +gem 'fog-aws', '~> 2.0' gem 'fog-core', '~> 1.44' gem 'fog-google', '~> 0.5' gem 'fog-local', '~> 0.3' @@ -152,7 +152,7 @@ end gem 'state_machines-activerecord', '~> 0.4.0' # Issue tags -gem 'acts-as-taggable-on', '~> 4.0' +gem 'acts-as-taggable-on', '~> 5.0' # Background jobs gem 'sidekiq', '~> 5.0' @@ -208,7 +208,7 @@ gem 'asana', '~> 0.6.0' gem 'ruby-fogbugz', '~> 0.2.1' # Kubernetes integration -gem 'kubeclient', '~> 2.2.0' +gem 'kubeclient', '~> 3.0' # d3 gem 'd3_rails', '~> 3.5.0' @@ -257,14 +257,13 @@ gem 'font-awesome-rails', '~> 4.7' gem 'gemojione', '~> 3.3' gem 'gon', '~> 6.1.0' gem 'jquery-atwho-rails', '~> 1.3.2' -gem 'jquery-rails', '~> 4.3.1' gem 'request_store', '~> 1.3' gem 'select2-rails', '~> 3.5.9' gem 'virtus', '~> 1.0.1' gem 'base32', '~> 0.3.0' # Sentry integration -gem 'sentry-raven', '~> 2.5.3' +gem 'sentry-raven', '~> 2.7' gem 'premailer-rails', '~> 1.9.7' @@ -300,7 +299,7 @@ group :metrics do end group :development do - gem 'foreman', '~> 0.78.0' + gem 'foreman', '~> 0.84.0' gem 'brakeman', '~> 3.6.0', require: false gem 'letter_opener_web', '~> 1.3.0' @@ -360,7 +359,7 @@ group :development, :test do gem 'benchmark-ips', '~> 2.3.0', require: false gem 'license_finder', '~> 3.1', require: false - gem 'knapsack', '~> 1.11.0' + gem 'knapsack', '~> 1.16' gem 'activerecord_sane_schema_dumper', '0.2' @@ -381,14 +380,14 @@ group :test do gem 'test-prof', '~> 0.2.5' end -gem 'octokit', '~> 4.6.2' +gem 'octokit', '~> 4.8' gem 'mail_room', '~> 0.9.1' gem 'email_reply_trimmer', '~> 0.1' gem 'html2text' -gem 'ruby-prof', '~> 0.16.2' +gem 'ruby-prof', '~> 0.17.0' # OAuth gem 'oauth2', '~> 1.4' @@ -401,7 +400,7 @@ gem 'vmstat', '~> 2.3.0' gem 'sys-filesystem', '~> 1.1.6' # SSH host key support -gem 'net-ssh', '~> 4.1.0' +gem 'net-ssh', '~> 4.2.0' gem 'sshkey', '~> 1.9.0' # Required for ED25519 SSH host key support @@ -421,9 +420,9 @@ gem 'google-protobuf', '= 3.5.1' gem 'toml-rb', '~> 1.0.0', require: false # Feature toggles -gem 'flipper', '~> 0.11.0' -gem 'flipper-active_record', '~> 0.11.0' -gem 'flipper-active_support_cache_store', '~> 0.11.0' +gem 'flipper', '~> 0.13.0' +gem 'flipper-active_record', '~> 0.13.0' +gem 'flipper-active_support_cache_store', '~> 0.13.0' # Structured logging gem 'lograge', '~> 0.5' diff --git a/Gemfile.lock b/Gemfile.lock index b85c7085d07..618fbf4b3e7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -40,8 +40,8 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - acts-as-taggable-on (4.0.0) - activerecord (>= 4.0) + acts-as-taggable-on (5.0.0) + activerecord (>= 4.2.8) adamantium (0.2.0) ice_nine (~> 0.11.0) memoizable (~> 0.4.0) @@ -174,7 +174,7 @@ GEM diff-lcs (1.3) diffy (3.1.0) docile (1.1.5) - domain_name (0.5.20161021) + domain_name (0.5.20170404) unf (>= 0.0.5, < 1.0.0) doorkeeper (4.2.6) railties (>= 4.2) @@ -194,7 +194,7 @@ GEM et-orbi (1.0.3) tzinfo eventmachine (1.0.8) - excon (0.57.1) + excon (0.60.0) execjs (2.6.0) expression_parser (0.9.0) factory_bot (4.8.2) @@ -218,13 +218,13 @@ GEM path_expander (~> 1.0) ruby_parser (~> 3.0) sexp_processor (~> 4.0) - flipper (0.11.0) - flipper-active_record (0.11.0) + flipper (0.13.0) + flipper-active_record (0.13.0) activerecord (>= 3.2, < 6) - flipper (~> 0.11.0) - flipper-active_support_cache_store (0.11.0) + flipper (~> 0.13.0) + flipper-active_support_cache_store (0.13.0) activesupport (>= 3.2, < 6) - flipper (~> 0.11.0) + flipper (~> 0.13.0) flowdock (0.7.1) httparty (~> 0.7) multi_json @@ -233,14 +233,14 @@ GEM fog-json (~> 1.0) ipaddress (~> 0.8) xml-simple (~> 1.1) - fog-aws (1.4.0) + fog-aws (2.0.1) fog-core (~> 1.38) fog-json (~> 1.0) fog-xml (~> 0.1) ipaddress (~> 0.8) - fog-core (1.44.3) + fog-core (1.45.0) builder - excon (~> 0.49) + excon (~> 0.58) formatador (~> 0.2) fog-google (0.5.3) fog-core @@ -265,7 +265,7 @@ GEM nokogiri (>= 1.5.11, < 2.0.0) font-awesome-rails (4.7.0.1) railties (>= 3.2, < 5.1) - foreman (0.78.0) + foreman (0.84.0) thor (~> 0.19.1) formatador (0.2.5) fuubar (2.2.0) @@ -388,7 +388,7 @@ GEM thor tilt hashdiff (0.3.4) - hashie (3.5.6) + hashie (3.5.7) hashie-forbidden_attributes (0.1.1) hashie (>= 3.0) health_check (2.6.0) @@ -402,20 +402,20 @@ GEM html2text (0.2.0) nokogiri (~> 1.6) htmlentities (4.3.4) - http (0.9.8) + http (2.2.2) 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.1) + 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.2) - i18n (0.9.1) + i18n (0.9.5) concurrent-ruby (~> 1.0) ice_nine (0.11.2) influxdb (0.2.3) @@ -427,10 +427,6 @@ GEM 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.7.2) activesupport @@ -454,13 +450,13 @@ GEM kaminari-core (= 1.0.1) kaminari-core (1.0.1) kgio (2.10.0) - knapsack (1.11.0) + knapsack (1.16.0) rake timecop (>= 0.1.0) - kubeclient (2.2.0) - http (= 0.9.8) - recursive-open-struct (= 1.0.0) - rest-client + kubeclient (3.0.0) + http (~> 2.2.2) + recursive-open-struct (~> 1.0.4) + rest-client (~> 2.0) launchy (2.4.3) addressable (~> 2.3) letter_opener (1.4.1) @@ -513,7 +509,7 @@ GEM mustermann (~> 1.0.0) mysql2 (0.4.10) net-ldap (0.16.0) - net-ssh (4.1.0) + net-ssh (4.2.0) netrc (0.11.0) nokogiri (1.8.2) mini_portile2 (~> 2.3.0) @@ -525,12 +521,12 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - octokit (4.6.2) + octokit (4.8.0) sawyer (~> 0.8.0, >= 0.5.3) oj (2.17.5) - omniauth (1.4.2) + omniauth (1.4.3) hashie (>= 1.2, < 4) - rack (>= 1.0, < 3) + rack (>= 1.6.2, < 3) omniauth-auth0 (1.4.1) omniauth-oauth2 (~> 1.1) omniauth-authentiq (0.3.1) @@ -569,9 +565,9 @@ GEM omniauth (~> 1.2) omniauth-oauth2-generic (0.2.2) omniauth-oauth2 (~> 1.0) - omniauth-saml (1.7.0) - omniauth (~> 1.3) - ruby-saml (~> 1.4) + omniauth-saml (1.10.0) + omniauth (~> 1.3, >= 1.3.2) + ruby-saml (~> 1.7) omniauth-shibboleth (1.2.1) omniauth (>= 1.0.0) omniauth-twitter (1.2.1) @@ -650,7 +646,7 @@ GEM pry (>= 0.9.10) public_suffix (3.0.2) pyu-ruby-sasl (0.0.3.3) - rack (1.6.8) + rack (1.6.9) rack-accept (0.4.5) rack (>= 0.4) rack-attack (4.4.1) @@ -713,7 +709,7 @@ GEM re2 (1.1.1) recaptcha (3.0.0) json - recursive-open-struct (1.0.0) + recursive-open-struct (1.0.5) redcarpet (3.4.0) redis (3.3.5) redis-actionpack (5.0.2) @@ -741,7 +737,7 @@ GEM request_store (1.3.1) responders (2.3.0) railties (>= 4.2.0, < 5.1) - rest-client (2.0.0) + rest-client (2.0.2) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) @@ -803,9 +799,9 @@ GEM i18n ruby-fogbugz (0.2.1) crack (~> 0.4) - ruby-prof (0.16.2) + ruby-prof (0.17.0) ruby-progressbar (1.9.0) - ruby-saml (1.4.1) + ruby-saml (1.7.2) nokogiri (>= 1.5.10) ruby_parser (3.9.0) sexp_processor (~> 4.1) @@ -844,7 +840,7 @@ GEM selenium-webdriver (3.5.0) childprocess (~> 0.5) rubyzip (~> 1.0) - sentry-raven (2.5.3) + sentry-raven (2.7.2) faraday (>= 0.7.6, < 1.0) settingslogic (2.0.9) sexp_processor (4.9.0) @@ -933,7 +929,7 @@ GEM truncato (0.7.10) htmlentities (~> 4.3.1) nokogiri (~> 1.8.0, >= 1.7.0) - tzinfo (1.2.4) + tzinfo (1.2.5) thread_safe (~> 0.1) u2f (0.2.1) uber (0.1.0) @@ -942,7 +938,7 @@ GEM json (>= 1.8.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.4) + unf_ext (0.0.7.5) unicode-display_width (1.3.0) unicorn (5.1.0) kgio (~> 2.6) @@ -994,7 +990,7 @@ DEPENDENCIES RedCloth (~> 4.3.2) ace-rails-ap (~> 4.1.0) activerecord_sane_schema_dumper (= 0.2) - acts-as-taggable-on (~> 4.0) + acts-as-taggable-on (~> 5.0) addressable (~> 2.5.2) akismet (~> 2.0) allocations (~> 1.0) @@ -1044,18 +1040,18 @@ DEPENDENCIES 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) + flipper (~> 0.13.0) + flipper-active_record (~> 0.13.0) + flipper-active_support_cache_store (~> 0.13.0) fog-aliyun (~> 0.2.0) - fog-aws (~> 1.4) + fog-aws (~> 2.0) 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) + foreman (~> 0.84.0) fuubar (~> 2.2.0) gemnasium-gitlab-service (~> 0.2) gemojione (~> 3.3) @@ -1090,12 +1086,11 @@ DEPENDENCIES 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) + knapsack (~> 1.16) + kubeclient (~> 3.0) letter_opener_web (~> 1.3.0) license_finder (~> 3.1) licensee (~> 8.7.0) @@ -1107,10 +1102,10 @@ DEPENDENCIES mousetrap-rails (~> 1.4.6) mysql2 (~> 0.4.10) net-ldap - net-ssh (~> 4.1.0) + net-ssh (~> 4.2.0) nokogiri (~> 1.8.2) oauth2 (~> 1.4) - octokit (~> 4.6.2) + octokit (~> 4.8) oj (~> 2.17.4) omniauth (~> 1.4.2) omniauth-auth0 (~> 1.4.1) @@ -1123,7 +1118,7 @@ DEPENDENCIES omniauth-google-oauth2 (~> 0.5.2) omniauth-kerberos (~> 0.3.0) omniauth-oauth2-generic (~> 0.2.2) - omniauth-saml (~> 1.7.0) + omniauth-saml (~> 1.10.0) omniauth-shibboleth (~> 1.2.0) omniauth-twitter (~> 1.2.0) omniauth_crowd (~> 2.2.0) @@ -1173,7 +1168,7 @@ DEPENDENCIES rubocop (~> 0.52.1) rubocop-rspec (~> 1.22.1) ruby-fogbugz (~> 0.2.1) - ruby-prof (~> 0.16.2) + ruby-prof (~> 0.17.0) ruby_parser (~> 3.8) rufus-scheduler (~> 3.4) rugged (~> 0.26.0) @@ -1183,7 +1178,7 @@ DEPENDENCIES seed-fu (~> 2.3.7) select2-rails (~> 3.5.9) selenium-webdriver (~> 3.5) - sentry-raven (~> 2.5.3) + sentry-raven (~> 2.7) settingslogic (~> 2.0.9) sham_rack (~> 1.3.6) shoulda-matchers (~> 3.1.2) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 43a5325cf71..8259133c95b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -132,9 +132,8 @@ class GfmAutoComplete { callbacks: { ...this.getDefaultCallbacks(), matcher(flag, subtext) { - const relevantText = subtext.trim().split(/\s/).pop(); const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); - const match = regexp.exec(relevantText); + const match = regexp.exec(subtext); return match && match.length ? match[1] : null; }, diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index f01aef45500..e77318fef46 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -73,6 +73,7 @@ export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { const mergeRequestTabs = document.querySelector('.js-tabs-affix'); const navbar = document.querySelector('.navbar-gitlab'); + const peek = document.getElementById('peek'); const paddingTop = 16; this.diffsLoaded = false; @@ -86,6 +87,10 @@ export default class MergeRequestTabs { this.showTab = this.showTab.bind(this); this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; + if (peek) { + this.stickyTop += peek.offsetHeight; + } + if (mergeRequestTabs) { this.stickyTop += mergeRequestTabs.offsetHeight; } diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 9e67a6f2146..42615d2bb8e 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -209,6 +209,7 @@ const xAxis = d3.axisBottom() .scale(axisXScale) + .ticks(this.graphWidth / 120) .tickFormat(timeScaleFormat); const yAxis = d3.axisLeft() diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 972fdb2b791..096c4ef5f31 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -4,13 +4,15 @@ import discussionCounter from '../notes/components/discussion_counter.vue'; import store from '../notes/stores'; export default function initMrNotes() { - new Vue({ // eslint-disable-line + // eslint-disable-next-line no-new + new Vue({ el: '#js-vue-mr-discussions', components: { notesApp, }, data() { - const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + const notesDataset = document.getElementById('js-vue-mr-discussions') + .dataset; return { noteableData: JSON.parse(notesDataset.noteableData), currentUserData: JSON.parse(notesDataset.currentUserData), @@ -28,7 +30,8 @@ export default function initMrNotes() { }, }); - new Vue({ // eslint-disable-line + // eslint-disable-next-line no-new + new Vue({ el: '#js-vue-discussion-counter', components: { discussionCounter, diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c640003d958..659ae575219 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -16,6 +16,10 @@ import Autosize from 'autosize'; import 'vendor/jquery.caret'; // required by jquery.atwho import 'vendor/jquery.atwho'; import AjaxCache from '~/lib/utils/ajax_cache'; +import Vue from 'vue'; +import syntaxHighlight from '~/syntax_highlight'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; @@ -24,7 +28,13 @@ import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; import Autosave from './autosave'; import TaskList from './task_list'; -import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils'; +import { + isInViewport, + getPagePath, + scrollToElement, + isMetaKey, + hasVueMRDiscussionsCookie, +} from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -38,9 +48,21 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; export default class Notes { - static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + static initialize( + notes_url, + note_ids, + last_fetched_at, + view, + enableGFM = true, + ) { if (!this.instance) { - this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM); + this.instance = new Notes( + notes_url, + note_ids, + last_fetched_at, + view, + enableGFM, + ); } } @@ -78,7 +100,8 @@ export default class Notes { this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; - this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge')); + this.notesCountBadge || + (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge')); this.basePollingInterval = 15000; this.maxPollingSteps = 4; @@ -89,15 +112,24 @@ export default class Notes { this.taskList = new TaskList({ dataType: 'note', fieldName: 'note', - selector: '.notes' + selector: '.notes', }); this.collapseLongCommitList(); this.setViewType(view); // We are in the Merge Requests page so we need another edit form for Changes tab if (getPagePath(1) === 'merge_requests') { - $('.note-edit-form').clone() - .addClass('mr-note-edit-form').insertAfter('.note-edit-form'); + $('.note-edit-form') + .clone() + .addClass('mr-note-edit-form') + .insertAfter('.note-edit-form'); + } + + const hash = getLocationHash(); + const $anchor = hash && document.getElementById(hash); + + if ($anchor) { + this.loadLazyDiff({ currentTarget: $anchor }); } } @@ -106,7 +138,9 @@ export default class Notes { } addBinding() { - this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document); + this.$wrapperEl = hasVueMRDiscussionsCookie() + ? $(document).find('.diffs') + : $(document); // Edit note link this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); @@ -114,36 +148,78 @@ export default class Notes { // Reopen and close actions for Issue/MR combined with note form submit this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); - this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); + this.$wrapperEl.on( + 'keyup input', + '.js-note-text', + this.updateTargetButtons, + ); // resolve a discussion this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); + this.$wrapperEl.on( + 'click', + '.js-note-attachment-delete', + this.removeAttachment, + ); // reset main target form when clicking discard this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); + this.$wrapperEl.on( + 'change', + '.js-note-attachment-input', + this.updateFormAttachment, + ); // reply to diff/discussion notes - this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + this.$wrapperEl.on( + 'click', + '.js-discussion-reply-button', + this.onReplyToDiscussionNote, + ); // add diff note this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); + this.$wrapperEl.on( + 'click', + '.js-add-image-diff-note-button', + this.onAddImageDiffNote, + ); // hide diff note form - this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); + this.$wrapperEl.on( + 'click', + '.js-close-discussion-note-form', + this.cancelDiscussionForm, + ); // toggle commit list - this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); + this.$wrapperEl.on( + 'click', + '.system-note-commit-list-toggler', + this.toggleCommitList, + ); + + this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); // fetch notes when tab becomes visible this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote); - this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); - this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); - this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); + this.$wrapperEl.on( + 'ajax:success', + '.js-discussion-note-form', + this.addDiscussionNote, + ); + this.$wrapperEl.on( + 'ajax:success', + '.js-main-target-form', + this.resetMainTargetForm, + ); + this.$wrapperEl.on( + 'ajax:complete', + '.js-main-target-form', + this.reenableTargetFormSubmitButton, + ); // when a key is clicked on the notes this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // When the URL fragment/hash has changed, `#note_xxx` @@ -173,6 +249,7 @@ export default class Notes { this.$wrapperEl.off('keydown', '.js-note-text'); this.$wrapperEl.off('click', '.js-comment-resolve-button'); this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); + this.$wrapperEl.off('click', '.js-toggle-lazy-diff'); this.$wrapperEl.off('ajax:success', '.js-main-target-form'); this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); @@ -181,10 +258,16 @@ export default class Notes { } static initCommentTypeToggle(form) { - const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); - const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); + const dropdownTrigger = form.querySelector( + '.js-comment-type-dropdown .dropdown-toggle', + ); + const dropdownList = form.querySelector( + '.js-comment-type-dropdown .dropdown-menu', + ); const noteTypeInput = form.querySelector('#note_type'); - const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); + const submitButton = form.querySelector( + '.js-comment-type-dropdown .js-comment-submit-button', + ); const closeButton = form.querySelector('.js-note-target-close'); const reopenButton = form.querySelector('.js-note-target-reopen'); @@ -201,7 +284,13 @@ export default class Notes { } keydownNoteText(e) { - var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; + var $textarea, + discussionNoteForm, + editNote, + myLastNote, + myLastNoteEditBtn, + newText, + originalText; if (isMetaKey(e)) { return; } @@ -213,7 +302,12 @@ export default class Notes { if ($textarea.val() !== '') { return; } - myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes')); + myLastNote = $( + `li.note[data-author-id='${ + gon.current_user_id + }'][data-editable]:last`, + $textarea.closest('.note, .notes_holder, #notes'), + ); if (myLastNote.length) { myLastNoteEditBtn = myLastNote.find('.js-note-edit'); return myLastNoteEditBtn.trigger('click', [true, myLastNote]); @@ -224,7 +318,9 @@ export default class Notes { discussionNoteForm = $textarea.closest('.js-discussion-note-form'); if (discussionNoteForm.length) { if ($textarea.val() !== '') { - if (!confirm('Are you sure you want to cancel creating this comment?')) { + if ( + !confirm('Are you sure you want to cancel creating this comment?') + ) { return; } } @@ -236,7 +332,9 @@ export default class Notes { originalText = $textarea.closest('form').data('originalNote'); newText = $textarea.val(); if (originalText !== newText) { - if (!confirm('Are you sure you want to cancel editing this comment?')) { + if ( + !confirm('Are you sure you want to cancel editing this comment?') + ) { return; } } @@ -249,11 +347,14 @@ export default class Notes { if (Notes.interval) { clearInterval(Notes.interval); } - return Notes.interval = setInterval((function(_this) { - return function() { - return _this.refresh(); - }; - })(this), this.pollingInterval); + return (Notes.interval = setInterval( + (function(_this) { + return function() { + return _this.refresh(); + }; + })(this), + this.pollingInterval, + )); } refresh() { @@ -269,20 +370,23 @@ export default class Notes { this.refreshing = true; - axios.get(`${this.notes_url}?html=true`, { - headers: { - 'X-Last-Fetched-At': this.last_fetched_at, - }, - }).then(({ data }) => { - const notes = data.notes; - this.last_fetched_at = data.last_fetched_at; - this.setPollingInterval(data.notes.length); - $.each(notes, (i, note) => this.renderNote(note)); - - this.refreshing = false; - }).catch(() => { - this.refreshing = false; - }); + axios + .get(`${this.notes_url}?html=true`, { + headers: { + 'X-Last-Fetched-At': this.last_fetched_at, + }, + }) + .then(({ data }) => { + const notes = data.notes; + this.last_fetched_at = data.last_fetched_at; + this.setPollingInterval(data.notes.length); + $.each(notes, (i, note) => this.renderNote(note)); + + this.refreshing = false; + }) + .catch(() => { + this.refreshing = false; + }); } /** @@ -298,7 +402,8 @@ export default class Notes { if (shouldReset == null) { shouldReset = true; } - nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + nthInterval = + this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); if (shouldReset) { this.pollingInterval = this.basePollingInterval; } else if (this.pollingInterval < nthInterval) { @@ -317,12 +422,17 @@ export default class Notes { if ('emoji_award' in noteEntity.commands_changes) { votesBlock = $('.js-awards-block').eq(0); - loadAwardsHandler().then((awardsHandler) => { - awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); - awardsHandler.scrollToAwards(); - }).catch(() => { - // ignore - }); + loadAwardsHandler() + .then(awardsHandler => { + awardsHandler.addAwardToEmojiBar( + votesBlock, + noteEntity.commands_changes.emoji_award, + ); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + // ignore + }); } } } @@ -367,11 +477,17 @@ export default class Notes { if (!noteEntity.valid) { if (noteEntity.errors && noteEntity.errors.commands_only) { - if (noteEntity.commands_changes && - Object.keys(noteEntity.commands_changes).length > 0) { + if ( + noteEntity.commands_changes && + Object.keys(noteEntity.commands_changes).length > 0 + ) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0)); + this.addFlash( + noteEntity.errors.commands_only, + 'notice', + this.parentTimeline.get(0), + ); this.refresh(); } return; @@ -393,28 +509,30 @@ export default class Notes { this.setupNewNote($newNote); this.refresh(); return this.updateNotesCount(1); - } - // The server can send the same update multiple times so we need to make sure to only update once per actual update. - else if (Notes.isUpdatedNote(noteEntity, $note)) { + } else if (Notes.isUpdatedNote(noteEntity, $note)) { + // The server can send the same update multiple times so we need to make sure to only update once per actual update. const isEditing = $note.hasClass('is-editing'); const initialContent = normalizeNewlines( - $note.find('.original-note-content').text().trim() + $note + .find('.original-note-content') + .text() + .trim(), ); const $textarea = $note.find('.js-note-text'); const currentContent = $textarea.val(); // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way const sanitizedNoteNote = normalizeNewlines(noteEntity.note); - const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote; + const isTextareaUntouched = + currentContent === initialContent || + currentContent === sanitizedNoteNote; if (isEditing && isTextareaUntouched) { $textarea.val(noteEntity.note); this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; - } - else if (isEditing && !isTextareaUntouched) { + } else if (isEditing && !isTextareaUntouched) { this.putConflictEditWarningInPlace(noteEntity, $note); this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; - } - else { + } else { const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); this.setupNewNote($updatedNote); } @@ -438,17 +556,31 @@ export default class Notes { } this.note_ids.push(noteEntity.id); - form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); - row = (form.length || !noteEntity.discussion_line_code) ? form.closest('tr') : $(`#${noteEntity.discussion_line_code}`); + form = + $form || + $( + `.js-discussion-note-form[data-discussion-id="${ + noteEntity.discussion_id + }"]`, + ); + row = + form.length || !noteEntity.discussion_line_code + ? form.closest('tr') + : $(`#${noteEntity.discussion_line_code}`); if (noteEntity.on_image) { row = form; } lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; - diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); + diffAvatarContainer = row + .prevAll('.line_holder') + .first() + .find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); + discussionContainer = $( + `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, + ); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } @@ -456,25 +588,42 @@ export default class Notes { if (noteEntity.diff_discussion_html) { var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { + if ( + !this.isParallelView() || + row.hasClass('js-temp-notes-holder') || + noteEntity.on_image + ) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); - var contentContainerClass = '.' + $notes.closest('.notes_content') - .attr('class') - .split(' ') - .join('.'); - - row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); + var $notes = $discussion.find( + `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, + ); + var contentContainerClass = + '.' + + $notes + .closest('.notes_content') + .attr('class') + .split(' ') + .join('.'); + + row + .find(contentContainerClass + ' .content') + .append($notes.closest('.content').children()); } } // Init discussion on 'Discussion' page if it is merge request page const page = $('body').attr('data-page'); - if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { + if ( + (page && page.indexOf('projects:merge_request') !== -1) || + !noteEntity.diff_discussion_html + ) { if (!hasVueMRDiscussionsCookie()) { - Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + Notes.animateAppendNote( + noteEntity.discussion_html, + $('.main-notes-list'), + ); } } } else { @@ -482,7 +631,10 @@ export default class Notes { Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { + if ( + typeof gl.diffNotesCompileComponents !== 'undefined' && + noteEntity.discussion_resolvable + ) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); @@ -494,7 +646,8 @@ export default class Notes { } getLineHolder(changesDiscussionContainer) { - return $(changesDiscussionContainer).closest('.notes_holder') + return $(changesDiscussionContainer) + .closest('.notes_holder') .prevAll('.line_holder') .first() .get(0); @@ -527,8 +680,14 @@ export default class Notes { form.find('.js-errors').remove(); // reset text and preview form.find('.js-md-write-button').click(); - form.find('.js-note-text').val('').trigger('input'); - form.find('.js-note-text').data('autosave').reset(); + form + .find('.js-note-text') + .val('') + .trigger('input'); + form + .find('.js-note-text') + .data('autosave') + .reset(); var event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); @@ -564,7 +723,10 @@ export default class Notes { form.find('#note_type').val(''); form.find('#note_project_id').remove(); form.find('#in_reply_to_discussion_id').remove(); - form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); + form + .find('.js-comment-resolve-button') + .closest('comment-and-resolve-btn') + .remove(); this.parentTimeline = form.parents('.timeline'); if (form.length) { @@ -618,11 +780,17 @@ export default class Notes { } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } - return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline.get(0)); + return this.addFlash( + 'Your comment could not be submitted! Please check your network connection and try again.', + 'alert', + formParentTimeline.get(0), + ); } updateNoteError($parentTimeline) { - new Flash('Your comment could not be updated! Please check your network connection and try again.'); + new Flash( + 'Your comment could not be updated! Please check your network connection and try again.', + ); } /** @@ -671,14 +839,16 @@ export default class Notes { } checkContentToAllowEditing($el) { - var initialContent = $el.find('.original-note-content').text().trim(); + var initialContent = $el + .find('.original-note-content') + .text() + .trim(); var currentContent = $el.find('.js-note-text').val(); var isAllowed = true; if (currentContent === initialContent) { this.removeNoteEditForm($el); - } - else { + } else { var $buttons = $el.find('.note-form-actions'); var isWidgetVisible = isInViewport($el.get(0)); @@ -740,8 +910,7 @@ export default class Notes { this.setupNewNote($newNote); // Now that we have taken care of the update, clear it out delete this.updatedNotesTrackingMap[noteId]; - } - else { + } else { $note.find('.js-finish-edit-warning').hide(); this.removeNoteEditForm($note); } @@ -774,7 +943,9 @@ export default class Notes { form.removeClass('current-note-edit-form'); form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. - return form.find('.js-note-text').val(form.find('form.edit-note').data('originalNote')); + return form + .find('.js-note-text') + .val(form.find('form.edit-note').data('originalNote')); } /** @@ -788,58 +959,67 @@ export default class Notes { $note = $(e.currentTarget).closest('.note'); noteElId = $note.attr('id'); noteId = $note.attr('data-note-id'); - lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]') + lineHolder = $(e.currentTarget) + .closest('.notes[data-discussion-id]') .closest('.notes_holder') .prev('.line_holder'); - $(`.note[id="${noteElId}"]`).each((function(_this) { - // A same note appears in the "Discussion" and in the "Changes" tab, we have - // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, - // where $('#noteId') would return only one. - return function(i, el) { - var $note, $notes; - $note = $(el); - $notes = $note.closest('.discussion-notes'); - const discussionId = $('.notes', $notes).data('discussionId'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); + $(`.note[id="${noteElId}"]`).each( + (function(_this) { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. + return function(i, el) { + var $note, $notes; + $note = $(el); + $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussionId'); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + if (gl.diffNoteApps[noteElId]) { + gl.diffNoteApps[noteElId].$destroy(); + } } - } - - $note.remove(); - - // check if this is the last note for this line - if ($notes.find('.note').length === 0) { - var notesTr = $notes.closest('tr'); - - // "Discussions" tab - $notes.closest('.timeline-entry').remove(); - - $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); - - // The notes tr can contain multiple lists of notes, like on the parallel diff - // notesTr does not exist for image diffs - if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { - const $diffFile = $notes.closest('.diff-file'); - if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, - }); - $diffFile[0].dispatchEvent(removeBadgeEvent); + $note.remove(); + + // check if this is the last note for this line + if ($notes.find('.note').length === 0) { + var notesTr = $notes.closest('tr'); + + // "Discussions" tab + $notes.closest('.timeline-entry').remove(); + + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + + // The notes tr can contain multiple lists of notes, like on the parallel diff + // notesTr does not exist for image diffs + if ( + notesTr.find('.discussion-notes').length > 1 || + notesTr.length === 0 + ) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent( + 'removeBadge.imageDiff', + { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }, + ); + + $diffFile[0].dispatchEvent(removeBadgeEvent); + } + + $notes.remove(); + } else if (notesTr.length > 0) { + notesTr.remove(); } - - $notes.remove(); - } else if (notesTr.length > 0) { - notesTr.remove(); } - } - }; - })(this)); + }; + })(this), + ); Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); @@ -921,7 +1101,12 @@ export default class Notes { // DiffNote form.find('#note_position').val(dataHolder.attr('data-position')); - form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancelText')); + form + .find('.js-note-discard') + .show() + .removeClass('js-note-discard') + .addClass('js-close-discussion-note-form') + .text(form.find('.js-close-discussion-note-form').data('cancelText')); form.find('.js-note-target-close').remove(); form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); @@ -957,7 +1142,7 @@ export default class Notes { this.toggleDiffNote({ target: $link, lineType: link.dataset.lineType, - showReplyInput + showReplyInput, }); } @@ -973,7 +1158,9 @@ export default class Notes { // Setup comment form let newForm; - const $noteContainer = $link.closest('.diff-viewer').find('.note-container'); + const $noteContainer = $link + .closest('.diff-viewer') + .find('.note-container'); const $form = $noteContainer.find('> .discussion-form'); if ($form.length === 0) { @@ -986,13 +1173,17 @@ export default class Notes { this.setupDiscussionNoteForm($link, newForm); } - toggleDiffNote({ - target, - lineType, - forceShow, - showReplyInput = false, - }) { - var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) { + var $link, + addForm, + hasNotes, + newForm, + noteForm, + replyButton, + row, + rowCssToAdd, + targetContent, + isDiffCommentAvatar; $link = $(target); row = $link.closest('tr'); const nextRow = row.next(); @@ -1004,11 +1195,13 @@ export default class Notes { hasNotes = nextRow.is('.notes_holder'); addForm = false; let lineTypeSelector = ''; - rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; + rowCssToAdd = + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { lineTypeSelector = `.${lineType}`; - rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; + rowCssToAdd = + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; } const notesContentSelector = `.notes_content${lineTypeSelector} .content`; let notesContent = targetRow.find(notesContentSelector); @@ -1036,7 +1229,9 @@ export default class Notes { notesContent = targetRow.find(notesContentSelector); addForm = true; } else { - const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); + const isCurrentlyShown = targetRow + .find('.content:not(:empty)') + .is(':visible'); const isForced = forceShow === true || forceShow === false; const showNow = forceShow === true || (!isCurrentlyShown && !isForced); @@ -1063,11 +1258,12 @@ export default class Notes { row = form.closest('tr'); glForm = form.data('glForm'); glForm.destroy(); - form.find('.js-note-text').data('autosave').reset(); - // show the reply button (will only work for replies) form - .prev('.discussion-reply-holder') - .show(); + .find('.js-note-text') + .data('autosave') + .reset(); + // show the reply button (will only work for replies) + form.prev('.discussion-reply-holder').show(); if (row.is('.js-temp-notes-holder')) { // remove temporary row for diff lines return row.remove(); @@ -1108,7 +1304,9 @@ export default class Notes { var filename, form; form = $(this).closest('form'); // get only the basename - filename = $(this).val().replace(/^.*[\\\/]/, ''); + filename = $(this) + .val() + .replace(/^.*[\\\/]/, ''); return form.find('.js-attachment-filename').text(filename); } @@ -1180,12 +1378,16 @@ export default class Notes { this.glForm = new GLForm($editForm.find('form'), this.enableGFM); - $editForm.find('form') + $editForm + .find('form') .attr('action', `${postUrl}?html=true`) .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); - $editForm.find('.js-note-text').focus().val(originalContent); + $editForm + .find('.js-note-text') + .focus() + .val(originalContent); $editForm.find('.js-md-write-button').trigger('click'); $editForm.find('.referenced-users').hide(); } @@ -1194,7 +1396,9 @@ export default class Notes { if ($note.find('.js-conflict-edit-warning').length === 0) { const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> This comment has changed since you started editing, please review the - <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> + <a href="#note_${ + noteEntity.id + }" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure information is not lost @@ -1204,14 +1408,79 @@ export default class Notes { } updateNotesCount(updateCount) { - return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); + return this.notesCountBadge.text( + parseInt(this.notesCountBadge.text(), 10) + updateCount, + ); + } + + static renderPlaceholderComponent($container) { + const el = $container.find('.js-code-placeholder').get(0); + new Vue({ + // eslint-disable-line no-new + el, + components: { + SkeletonLoadingContainer, + }, + render(createElement) { + return createElement('skeleton-loading-container'); + }, + }); + } + + static renderDiffContent($container, data) { + const { discussion_html } = data; + const lines = $(discussion_html).find('.line_holder'); + lines.addClass('fade-in'); + $container.find('tbody').prepend(lines); + const fileHolder = $container.find('.file-holder'); + $container.find('.line-holder-placeholder').remove(); + syntaxHighlight(fileHolder); + } + + static renderDiffError($container) { + $container.find('.line_content').html( + $(` + <div class="nothing-here-block"> + ${__( + 'Unable to load the diff.', + )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>? + </div> + `), + ); + } + + loadLazyDiff(e) { + const $container = $(e.currentTarget).closest('.js-toggle-container'); + Notes.renderPlaceholderComponent($container); + + $container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff'); + + const tableEl = $container.find('tbody'); + if (tableEl.length === 0) return; + + const fileHolder = $container.find('.file-holder'); + const url = fileHolder.data('linesPath'); + + axios + .get(url) + .then(({ data }) => { + Notes.renderDiffContent($container, data); + }) + .catch(() => { + Notes.renderDiffError($container); + }); } toggleCommitList(e) { const $element = $(e.currentTarget); - const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); + const $closestSystemCommitList = $element.siblings( + '.system-note-commit-list', + ); - $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up'); + $element + .find('.fa') + .toggleClass('fa-angle-down') + .toggleClass('fa-angle-up'); $closestSystemCommitList.toggleClass('hide-shade'); } @@ -1221,11 +1490,17 @@ export default class Notes { * intrusive. */ collapseLongCommitList() { - const systemNotes = $('#notes-list').find('li.system-note').has('ul'); + const systemNotes = $('#notes-list') + .find('li.system-note') + .has('ul'); $.each(systemNotes, function(index, systemNote) { const $systemNote = $(systemNote); - const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', ''); + const headerMessage = $systemNote + .find('.note-text') + .find('p:first') + .text() + .replace(':', ''); $systemNote.find('.note-header .system-note-message').html(headerMessage); @@ -1233,7 +1508,9 @@ export default class Notes { $systemNote.find('.note-text').addClass('system-note-commit-list'); $systemNote.find('.system-note-commit-list-toggler').show(); } else { - $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); + $systemNote + .find('.note-text') + .addClass('system-note-commit-list hide-shade'); } }); } @@ -1251,14 +1528,10 @@ export default class Notes { cleanForm($form) { // Remove JS classes that are not needed here - $form - .find('.js-comment-type-dropdown') - .removeClass('btn-group'); + $form.find('.js-comment-type-dropdown').removeClass('btn-group'); // Remove dropdown - $form - .find('.dropdown-menu') - .remove(); + $form.find('.dropdown-menu').remove(); return $form; } @@ -1277,7 +1550,11 @@ export default class Notes { // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim()); const currentNoteText = normalizeNewlines( - $note.find('.original-note-content').first().text().trim() + $note + .find('.original-note-content') + .first() + .text() + .trim(), ); return sanitizedNoteEntityText !== currentNoteText; } @@ -1367,7 +1644,14 @@ export default class Notes { * Once comment is _actually_ posted on server, we will have final element * in response that we will show in place of this temporary element. */ - createPlaceholderNote({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) { + createPlaceholderNote({ + formContent, + uniqueId, + isDiscussionNote, + currentUsername, + currentUserFullname, + currentUserAvatar, + }) { const discussionClass = isDiscussionNote ? 'discussion' : ''; const $tempNote = $( `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> @@ -1381,8 +1665,12 @@ export default class Notes { <div class="note-header"> <div class="note-header-info"> <a href="/${_.escape(currentUsername)}"> - <span class="hidden-xs">${_.escape(currentUsername)}</span> - <span class="note-headline-light">${_.escape(currentUsername)}</span> + <span class="hidden-xs">${_.escape( + currentUsername, + )}</span> + <span class="note-headline-light">${_.escape( + currentUsername, + )}</span> </a> </div> </div> @@ -1393,11 +1681,13 @@ export default class Notes { </div> </div> </div> - </li>` + </li>`, ); $tempNote.find('.hidden-xs').text(_.escape(currentUserFullname)); - $tempNote.find('.note-headline-light').text(`@${_.escape(currentUsername)}`); + $tempNote + .find('.note-headline-light') + .text(`@${_.escape(currentUsername)}`); return $tempNote; } @@ -1413,7 +1703,7 @@ export default class Notes { <i>${formContent}</i> </div> </div> - </li>` + </li>`, ); return $tempNote; @@ -1445,11 +1735,22 @@ export default class Notes { const $submitBtn = $(e.target); let $form = $submitBtn.parents('form'); const $closeBtn = $form.find('.js-note-target-close'); - const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion'; + const isDiscussionNote = + $submitBtn + .parent() + .find('li.droplab-item-selected') + .attr('id') === 'discussion'; const isMainForm = $form.hasClass('js-main-target-form'); const isDiscussionForm = $form.hasClass('js-discussion-note-form'); - const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); - const { formData, formContent, formAction, formContentOriginal } = this.getFormData($form); + const isDiscussionResolve = $submitBtn.hasClass( + 'js-comment-resolve-button', + ); + const { + formData, + formContent, + formAction, + formContentOriginal, + } = this.getFormData($form); let noteUniqueId; let systemNoteUniqueId; let hasQuickActions = false; @@ -1479,23 +1780,30 @@ export default class Notes { // Show placeholder note if (tempFormContent) { noteUniqueId = _.uniqueId('tempNote_'); - $notesContainer.append(this.createPlaceholderNote({ - formContent: tempFormContent, - uniqueId: noteUniqueId, - isDiscussionNote, - currentUsername: gon.current_username, - currentUserFullname: gon.current_user_fullname, - currentUserAvatar: gon.current_user_avatar_url, - })); + $notesContainer.append( + this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId: noteUniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + currentUserAvatar: gon.current_user_avatar_url, + }), + ); } // Show placeholder system note if (hasQuickActions) { systemNoteUniqueId = _.uniqueId('tempSystemNote_'); - $notesContainer.append(this.createPlaceholderSystemNote({ - formContent: this.getQuickActionDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), - uniqueId: systemNoteUniqueId, - })); + $notesContainer.append( + this.createPlaceholderSystemNote({ + formContent: this.getQuickActionDescription( + formContent, + AjaxCache.get(gl.GfmAutoComplete.dataSources.commands), + ), + uniqueId: systemNoteUniqueId, + }), + ); } // Clear the form textarea @@ -1509,8 +1817,9 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios.post(`${formAction}?html=true`, formData) - .then((res) => { + axios + .post(`${formAction}?html=true`, formData) + .then(res => { const note = res.data; // Submission successful! remove placeholder @@ -1527,7 +1836,9 @@ export default class Notes { // Reset cached commands list when command is applied if (hasQuickActions) { - $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); + $form + .find('textarea.js-note-text') + .trigger('clear-commands-cache.atwho'); } // Clear previous form errors @@ -1572,11 +1883,14 @@ export default class Notes { // append flash-container to the Notes list if ($notesContainer.length) { - $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); + $notesContainer.append( + '<div class="flash-container" style="display: none;"></div>', + ); } Notes.refreshVueNotes(); - } else if (isMainForm) { // Check if this was main thread comment + } else if (isMainForm) { + // Check if this was main thread comment // Show final note element on UI and perform form and action buttons cleanup this.addNote($form, note); this.reenableTargetFormSubmitButton(e); @@ -1587,7 +1901,8 @@ export default class Notes { } $form.trigger('ajax:success', [note]); - }).catch(() => { + }) + .catch(() => { // Submission failed, remove placeholder note and show Flash error message $notesContainer.find(`#${noteUniqueId}`).remove(); @@ -1607,7 +1922,9 @@ export default class Notes { // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { - const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + const replyButton = $notesContainer + .parent() + .find('.js-discussion-reply-button'); this.replyToDiscussionNote(replyButton[0]); $form = $notesContainer.parent().find('form'); } @@ -1652,12 +1969,19 @@ export default class Notes { // Show updated comment content temporarily $noteBodyText.html(formContent); - $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); - $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); + $editingNote + .removeClass('is-editing fade-in-full') + .addClass('being-posted fade-in-half'); + $editingNote + .find('.note-headline-meta a') + .html( + '<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>', + ); /* eslint-disable promise/catch-or-return */ // Make request to update comment on server - axios.post(`${formAction}?html=true`, formData) + axios + .post(`${formAction}?html=true`, formData) .then(({ data }) => { // Submission successful! render final note element this.updateNote(data, $editingNote); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 1785be01a0d..90dcafd75b7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -1,298 +1,311 @@ <script> - import $ from 'jquery'; - import { mapActions, mapGetters } from 'vuex'; - import _ from 'underscore'; - import Autosize from 'autosize'; - import { __, sprintf } from '~/locale'; - import Flash from '../../flash'; - import Autosave from '../../autosave'; - import TaskList from '../../task_list'; - import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility'; - import * as constants from '../constants'; - import eventHub from '../event_hub'; - import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; - import markdownField from '../../vue_shared/components/markdown/field.vue'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import loadingButton from '../../vue_shared/components/loading_button.vue'; - import noteSignedOutWidget from './note_signed_out_widget.vue'; - import discussionLockedWidget from './discussion_locked_widget.vue'; - import issuableStateMixin from '../mixins/issuable_state'; +import $ from 'jquery'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import _ from 'underscore'; +import Autosize from 'autosize'; +import { __, sprintf } from '~/locale'; +import Flash from '../../flash'; +import Autosave from '../../autosave'; +import TaskList from '../../task_list'; +import { + capitalizeFirstCharacter, + convertToCamelCase, +} from '../../lib/utils/text_utility'; +import * as constants from '../constants'; +import eventHub from '../event_hub'; +import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import markdownField from '../../vue_shared/components/markdown/field.vue'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import loadingButton from '../../vue_shared/components/loading_button.vue'; +import noteSignedOutWidget from './note_signed_out_widget.vue'; +import discussionLockedWidget from './discussion_locked_widget.vue'; +import issuableStateMixin from '../mixins/issuable_state'; - export default { - name: 'CommentForm', - components: { - issueWarning, - noteSignedOutWidget, - discussionLockedWidget, - markdownField, - userAvatarLink, - loadingButton, +export default { + name: 'CommentForm', + components: { + issueWarning, + noteSignedOutWidget, + discussionLockedWidget, + markdownField, + userAvatarLink, + loadingButton, + }, + mixins: [issuableStateMixin], + props: { + noteableType: { + type: String, + required: true, }, - mixins: [ - issuableStateMixin, - ], - props: { - noteableType: { - type: String, - required: true, - }, + }, + data() { + return { + note: '', + noteType: constants.COMMENT, + isSubmitting: false, + isSubmitButtonDisabled: true, + }; + }, + computed: { + ...mapGetters([ + 'getCurrentUserLastNote', + 'getUserData', + 'getNoteableData', + 'getNotesData', + 'openState', + ]), + ...mapState(['isToggleStateButtonLoading']), + noteableDisplayName() { + return this.noteableType.replace(/_/g, ' '); }, - data() { - return { - note: '', - noteType: constants.COMMENT, - isSubmitting: false, - isSubmitButtonDisabled: true, - }; + isLoggedIn() { + return this.getUserData.id; + }, + commentButtonTitle() { + return this.noteType === constants.COMMENT + ? 'Comment' + : 'Start discussion'; + }, + isOpen() { + return ( + this.openState === constants.OPENED || + this.openState === constants.REOPENED + ); }, - computed: { - ...mapGetters([ - 'getCurrentUserLastNote', - 'getUserData', - 'getNoteableData', - 'getNotesData', - 'openState', - ]), - noteableDisplayName() { - return this.noteableType.replace(/_/g, ' '); - }, - isLoggedIn() { - return this.getUserData.id; - }, - commentButtonTitle() { - return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; - }, - isOpen() { - return this.openState === constants.OPENED || this.openState === constants.REOPENED; - }, - canCreateNote() { - return this.getNoteableData.current_user.can_create_note; - }, - issueActionButtonTitle() { - const openOrClose = this.isOpen ? 'close' : 'reopen'; + canCreateNote() { + return this.getNoteableData.current_user.can_create_note; + }, + issueActionButtonTitle() { + const openOrClose = this.isOpen ? 'close' : 'reopen'; - if (this.note.length) { - return sprintf( - __('%{actionText} & %{openOrClose} %{noteable}'), - { - actionText: this.commentButtonTitle, - openOrClose, - noteable: this.noteableDisplayName, - }, - ); - } + if (this.note.length) { + return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), { + actionText: this.commentButtonTitle, + openOrClose, + noteable: this.noteableDisplayName, + }); + } - return sprintf( - __('%{openOrClose} %{noteable}'), - { - openOrClose: capitalizeFirstCharacter(openOrClose), - noteable: this.noteableDisplayName, - }, - ); - }, - actionButtonClassNames() { - return { - 'btn-reopen': !this.isOpen, - 'btn-close': this.isOpen, - 'js-note-target-close': this.isOpen, - 'js-note-target-reopen': !this.isOpen, - }; - }, - markdownDocsPath() { - return this.getNotesData.markdownDocsPath; - }, - quickActionsDocsPath() { - return this.getNotesData.quickActionsDocsPath; - }, - markdownPreviewPath() { - return this.getNoteableData.preview_note_path; - }, - author() { - return this.getUserData; - }, - canUpdateIssue() { - return this.getNoteableData.current_user.can_update; - }, - endpoint() { - return this.getNoteableData.create_note_path; - }, + return sprintf(__('%{openOrClose} %{noteable}'), { + openOrClose: capitalizeFirstCharacter(openOrClose), + noteable: this.noteableDisplayName, + }); }, - watch: { - note(newNote) { - this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); - }, - isSubmitting(newValue) { - this.setIsSubmitButtonDisabled(this.note, newValue); - }, + actionButtonClassNames() { + return { + 'btn-reopen': !this.isOpen, + 'btn-close': this.isOpen, + 'js-note-target-close': this.isOpen, + 'js-note-target-reopen': !this.isOpen, + }; }, - mounted() { - // jQuery is needed here because it is a custom event being dispatched with jQuery. - $(document).on('issuable:change', (e, isClosed) => { - this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED); - }); + markdownDocsPath() { + return this.getNotesData.markdownDocsPath; + }, + quickActionsDocsPath() { + return this.getNotesData.quickActionsDocsPath; + }, + markdownPreviewPath() { + return this.getNoteableData.preview_note_path; + }, + author() { + return this.getUserData; + }, + canUpdateIssue() { + return this.getNoteableData.current_user.can_update; + }, + endpoint() { + return this.getNoteableData.create_note_path; + }, + }, + watch: { + note(newNote) { + this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); + }, + isSubmitting(newValue) { + this.setIsSubmitButtonDisabled(this.note, newValue); + }, + }, + mounted() { + // jQuery is needed here because it is a custom event being dispatched with jQuery. + $(document).on('issuable:change', (e, isClosed) => { + this.toggleIssueLocalState( + isClosed ? constants.CLOSED : constants.REOPENED, + ); + }); - this.initAutoSave(); - this.initTaskList(); + this.initAutoSave(); + this.initTaskList(); + }, + methods: { + ...mapActions([ + 'saveNote', + 'stopPolling', + 'restartPolling', + 'removePlaceholderNotes', + 'closeIssue', + 'reopenIssue', + 'toggleIssueLocalState', + 'toggleStateButtonLoading', + ]), + setIsSubmitButtonDisabled(note, isSubmitting) { + if (!_.isEmpty(note) && !isSubmitting) { + this.isSubmitButtonDisabled = false; + } else { + this.isSubmitButtonDisabled = true; + } }, - methods: { - ...mapActions([ - 'saveNote', - 'stopPolling', - 'restartPolling', - 'removePlaceholderNotes', - 'closeIssue', - 'reopenIssue', - 'toggleIssueLocalState', - ]), - setIsSubmitButtonDisabled(note, isSubmitting) { - if (!_.isEmpty(note) && !isSubmitting) { - this.isSubmitButtonDisabled = false; - } else { - this.isSubmitButtonDisabled = true; - } - }, - handleSave(withIssueAction) { - this.isSubmitting = true; + handleSave(withIssueAction) { + this.isSubmitting = true; - if (this.note.length) { - const noteData = { - endpoint: this.endpoint, - flashContainer: this.$el, - data: { - note: { - noteable_type: this.noteableType, - noteable_id: this.getNoteableData.id, - note: this.note, - }, + if (this.note.length) { + const noteData = { + endpoint: this.endpoint, + flashContainer: this.$el, + data: { + note: { + noteable_type: this.noteableType, + noteable_id: this.getNoteableData.id, + note: this.note, }, - }; + }, + }; - if (this.noteType === constants.DISCUSSION) { - noteData.data.note.type = constants.DISCUSSION_NOTE; - } - this.note = ''; // Empty textarea while being requested. Repopulate in catch - this.resizeTextarea(); - this.stopPolling(); + if (this.noteType === constants.DISCUSSION) { + noteData.data.note.type = constants.DISCUSSION_NOTE; + } - this.saveNote(noteData) - .then((res) => { - this.isSubmitting = false; - this.restartPolling(); + this.note = ''; // Empty textarea while being requested. Repopulate in catch + this.resizeTextarea(); + this.stopPolling(); - if (res.errors) { - if (res.errors.commands_only) { - this.discard(); - } else { - Flash( - 'Something went wrong while adding your comment. Please try again.', - 'alert', - this.$refs.commentForm, - ); - } - } else { + this.saveNote(noteData) + .then(res => { + this.enableButton(); + this.restartPolling(); + + if (res.errors) { + if (res.errors.commands_only) { this.discard(); + } else { + Flash( + 'Something went wrong while adding your comment. Please try again.', + 'alert', + this.$refs.commentForm, + ); } + } else { + this.discard(); + } - if (withIssueAction) { - this.toggleIssueState(); - } - }) - .catch(() => { - this.isSubmitting = false; - this.discard(false); - const msg = - `Your comment could not be submitted! + if (withIssueAction) { + this.toggleIssueState(); + } + }) + .catch(() => { + this.enableButton(); + this.discard(false); + const msg = `Your comment could not be submitted! Please check your network connection and try again.`; - Flash(msg, 'alert', this.$el); - this.note = noteData.data.note.note; // Restore textarea content. - this.removePlaceholderNotes(); - }); - } else { - this.toggleIssueState(); - } - }, - enableButton() { - this.isSubmitting = false; - }, - toggleIssueState() { - if (this.isOpen) { - this.closeIssue() - .then(() => this.enableButton()) - .catch(() => { - this.enableButton(); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, + Flash(msg, 'alert', this.$el); + this.note = noteData.data.note.note; // Restore textarea content. + this.removePlaceholderNotes(); + }); + } else { + this.toggleIssueState(); + } + }, + enableButton() { + this.isSubmitting = false; + }, + toggleIssueState() { + if (this.isOpen) { + this.closeIssue() + .then(() => this.enableButton()) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __( + 'Something went wrong while closing the %{issuable}. Please try again later', ), - ); - }); - } else { - this.reopenIssue() - .then(() => this.enableButton()) - .catch(() => { - this.enableButton(); - Flash( - sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, + { issuable: this.noteableDisplayName }, + ), + ); + }); + } else { + this.reopenIssue() + .then(() => this.enableButton()) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __( + 'Something went wrong while reopening the %{issuable}. Please try again later', ), - ); - }); - } - }, - discard(shouldClear = true) { - // `blur` is needed to clear slash commands autocomplete cache if event fired. - // `focus` is needed to remain cursor in the textarea. - this.$refs.textarea.blur(); - this.$refs.textarea.focus(); + { issuable: this.noteableDisplayName }, + ), + ); + }); + } + }, + discard(shouldClear = true) { + // `blur` is needed to clear slash commands autocomplete cache if event fired. + // `focus` is needed to remain cursor in the textarea. + this.$refs.textarea.blur(); + this.$refs.textarea.focus(); - if (shouldClear) { - this.note = ''; - this.resizeTextarea(); - this.$refs.markdownField.previewMarkdown = false; - } + if (shouldClear) { + this.note = ''; + this.resizeTextarea(); + this.$refs.markdownField.previewMarkdown = false; + } - this.autosave.reset(); - }, - setNoteType(type) { - this.noteType = type; - }, - editCurrentUserLastNote() { - if (this.note === '') { - const lastNote = this.getCurrentUserLastNote; + this.autosave.reset(); + }, + setNoteType(type) { + this.noteType = type; + }, + editCurrentUserLastNote() { + if (this.note === '') { + const lastNote = this.getCurrentUserLastNote; - if (lastNote) { - eventHub.$emit('enterEditMode', { - noteId: lastNote.id, - }); - } + if (lastNote) { + eventHub.$emit('enterEditMode', { + noteId: lastNote.id, + }); } - }, - initAutoSave() { - if (this.isLoggedIn) { - const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); + } + }, + initAutoSave() { + if (this.isLoggedIn) { + const noteableType = capitalizeFirstCharacter( + convertToCamelCase(this.noteableType), + ); - this.autosave = new Autosave( - $(this.$refs.textarea), - ['Note', noteableType, this.getNoteableData.id], - ); - } - }, - initTaskList() { - return new TaskList({ - dataType: 'note', - fieldName: 'note', - selector: '.notes', - }); - }, - resizeTextarea() { - this.$nextTick(() => { - Autosize.update(this.$refs.textarea); - }); - }, + this.autosave = new Autosave($(this.$refs.textarea), [ + 'Note', + noteableType, + this.getNoteableData.id, + ]); + } + }, + initTaskList() { + return new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); }, - }; + resizeTextarea() { + this.$nextTick(() => { + Autosize.update(this.$refs.textarea); + }); + }, + }, +}; </script> <template> @@ -419,13 +432,13 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <loading-button v-if="canUpdateIssue" - :loading="isSubmitting" + :loading="isToggleStateButtonLoading" @click="handleSave(true)" :container-class="[ actionButtonClassNames, 'btn btn-comment btn-comment-and-close js-action-button' ]" - :disabled="isSubmitting" + :disabled="isToggleStateButtonLoading || isSubmitting" :label="issueActionButtonTitle" /> diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue index 3bcde17f07c..94d9dc69964 100644 --- a/app/assets/javascripts/notes/components/diff_file_header.vue +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -1,24 +1,24 @@ <script> - import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - import Icon from '~/vue_shared/components/icon.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - ClipboardButton, - Icon, +export default { + components: { + ClipboardButton, + Icon, + }, + props: { + diffFile: { + type: Object, + required: true, }, - props: { - diffFile: { - type: Object, - required: true, - }, + }, + computed: { + titleTag() { + return this.diffFile.discussionPath ? 'a' : 'span'; }, - computed: { - titleTag() { - return this.diffFile.discussionPath ? 'a' : 'span'; - }, - }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 1dba84fac18..ee01ec85bbb 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,56 +1,60 @@ <script> - import $ from 'jquery'; - import syntaxHighlight from '~/syntax_highlight'; - import imageDiffHelper from '~/image_diff/helpers/index'; - import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; - import DiffFileHeader from './diff_file_header.vue'; +import $ from 'jquery'; +import syntaxHighlight from '~/syntax_highlight'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import DiffFileHeader from './diff_file_header.vue'; - export default { - components: { - DiffFileHeader, +export default { + components: { + DiffFileHeader, + }, + props: { + discussion: { + type: Object, + required: true, }, - props: { - discussion: { - type: Object, - required: true, - }, + }, + computed: { + isImageDiff() { + return !this.diffFile.text; }, - computed: { - isImageDiff() { - return !this.diffFile.text; - }, - diffFileClass() { - const { text } = this.diffFile; - return text ? 'text-file' : 'js-image-file'; - }, - diffRows() { - return $(this.discussion.truncatedDiffLines); - }, - diffFile() { - return convertObjectPropsToCamelCase(this.discussion.diffFile); - }, - imageDiffHtml() { - return this.discussion.imageDiffHtml; - }, + diffFileClass() { + const { text } = this.diffFile; + return text ? 'text-file' : 'js-image-file'; }, - mounted() { - if (this.isImageDiff) { - const canCreateNote = false; - const renderCommentBadge = true; - imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); - } else { - const fileHolder = $(this.$refs.fileHolder); - this.$nextTick(() => { - syntaxHighlight(fileHolder); - }); - } + diffRows() { + return $(this.discussion.truncatedDiffLines); }, - methods: { - rowTag(html) { - return html.outerHTML ? 'tr' : 'template'; - }, + diffFile() { + return convertObjectPropsToCamelCase(this.discussion.diffFile); }, - }; + imageDiffHtml() { + return this.discussion.imageDiffHtml; + }, + }, + mounted() { + if (this.isImageDiff) { + const canCreateNote = false; + const renderCommentBadge = true; + imageDiffHelper.initImageDiff( + this.$refs.fileHolder, + canCreateNote, + renderCommentBadge, + ); + } else { + const fileHolder = $(this.$refs.fileHolder); + this.$nextTick(() => { + syntaxHighlight(fileHolder); + }); + } + }, + methods: { + rowTag(html) { + return html.outerHTML ? 'tr' : 'template'; + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 0158f58b569..d492d1cd001 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,67 +1,69 @@ <script> - import { mapGetters } from 'vuex'; - import resolveSvg from 'icons/_icon_resolve_discussion.svg'; - import resolvedSvg from 'icons/_icon_status_success_solid.svg'; - import mrIssueSvg from 'icons/_icon_mr_issue.svg'; - import nextDiscussionSvg from 'icons/_next_discussion.svg'; - import { pluralize } from '../../lib/utils/text_utility'; - import { scrollToElement } from '../../lib/utils/common_utils'; - import tooltip from '../../vue_shared/directives/tooltip'; +import { mapGetters } from 'vuex'; +import resolveSvg from 'icons/_icon_resolve_discussion.svg'; +import resolvedSvg from 'icons/_icon_status_success_solid.svg'; +import mrIssueSvg from 'icons/_icon_mr_issue.svg'; +import nextDiscussionSvg from 'icons/_next_discussion.svg'; +import { pluralize } from '../../lib/utils/text_utility'; +import { scrollToElement } from '../../lib/utils/common_utils'; +import tooltip from '../../vue_shared/directives/tooltip'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + 'getNoteableData', + 'discussionCount', + 'unresolvedDiscussions', + 'resolvedDiscussionCount', + ]), + isLoggedIn() { + return this.getUserData.id; }, - computed: { - ...mapGetters([ - 'getUserData', - 'getNoteableData', - 'discussionCount', - 'unresolvedDiscussions', - 'resolvedDiscussionCount', - ]), - isLoggedIn() { - return this.getUserData.id; - }, - hasNextButton() { - return this.isLoggedIn && !this.allResolved; - }, - countText() { - return pluralize('discussion', this.discussionCount); - }, - allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; - }, - resolveAllDiscussionsIssuePath() { - return this.getNoteableData.create_issue_to_resolve_discussions_path; - }, - firstUnresolvedDiscussionId() { - const item = this.unresolvedDiscussions[0] || {}; - - return item.id; - }, + hasNextButton() { + return this.isLoggedIn && !this.allResolved; + }, + countText() { + return pluralize('discussion', this.discussionCount); + }, + allResolved() { + return this.resolvedDiscussionCount === this.discussionCount; }, - created() { - this.resolveSvg = resolveSvg; - this.resolvedSvg = resolvedSvg; - this.mrIssueSvg = mrIssueSvg; - this.nextDiscussionSvg = nextDiscussionSvg; + resolveAllDiscussionsIssuePath() { + return this.getNoteableData.create_issue_to_resolve_discussions_path; + }, + firstUnresolvedDiscussionId() { + const item = this.unresolvedDiscussions[0] || {}; + + return item.id; }, - methods: { - jumpToFirstDiscussion() { - const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`); - const activeTab = window.mrTabs.currentAction; + }, + created() { + this.resolveSvg = resolveSvg; + this.resolvedSvg = resolvedSvg; + this.mrIssueSvg = mrIssueSvg; + this.nextDiscussionSvg = nextDiscussionSvg; + }, + methods: { + jumpToFirstDiscussion() { + const el = document.querySelector( + `[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`, + ); + const activeTab = window.mrTabs.currentAction; - if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.activateTab('show'); - } + if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.activateTab('show'); + } - if (el) { - scrollToElement(el); - } - }, + if (el) { + scrollToElement(el); + } }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index fc0722042cc..13283b187d1 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -1,15 +1,13 @@ <script> - import Icon from '~/vue_shared/components/icon.vue'; - import Issuable from '~/vue_shared/mixins/issuable'; +import Icon from '~/vue_shared/components/icon.vue'; +import Issuable from '~/vue_shared/mixins/issuable'; - export default { - components: { - Icon, - }, - mixins: [ - Issuable, - ], - }; +export default { + components: { + Icon, + }, + mixins: [Issuable], +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c26aa6fa15d..a7e2d857013 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,121 +1,119 @@ <script> - import { mapGetters } from 'vuex'; - import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; - import emojiSmile from 'icons/_emoji_smile.svg'; - import emojiSmiley from 'icons/_emoji_smiley.svg'; - import editSvg from 'icons/_icon_pencil.svg'; - import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; - import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; - import ellipsisSvg from 'icons/_ellipsis_v.svg'; - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; +import { mapGetters } from 'vuex'; +import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; +import emojiSmile from 'icons/_emoji_smile.svg'; +import emojiSmiley from 'icons/_emoji_smiley.svg'; +import editSvg from 'icons/_icon_pencil.svg'; +import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; +import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; +import ellipsisSvg from 'icons/_ellipsis_v.svg'; +import loadingIcon from '~/vue_shared/components/loading_icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; - export default { - name: 'NoteActions', - directives: { - tooltip, - }, - components: { - loadingIcon, - }, - props: { - authorId: { - type: Number, - required: true, - }, - noteId: { - type: Number, - required: true, - }, - accessLevel: { - type: String, - required: false, - default: '', - }, - reportAbusePath: { - type: String, - required: true, - }, - canEdit: { - type: Boolean, - required: true, - }, - canDelete: { - type: Boolean, - required: true, - }, - resolvable: { - type: Boolean, - required: false, - default: false, - }, - isResolved: { - type: Boolean, - required: false, - default: false, - }, - isResolving: { - type: Boolean, - required: false, - default: false, - }, - resolvedBy: { - type: Object, - required: false, - default: () => ({}), - }, - canReportAsAbuse: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapGetters([ - 'getUserDataByProp', - ]), - shouldShowActionsDropdown() { - return this.currentUserId && (this.canEdit || this.canReportAsAbuse); - }, - canAddAwardEmoji() { - return this.currentUserId; - }, - isAuthoredByCurrentUser() { - return this.authorId === this.currentUserId; - }, - currentUserId() { - return this.getUserDataByProp('id'); - }, - resolveButtonTitle() { - let title = 'Mark as resolved'; +export default { + name: 'NoteActions', + directives: { + tooltip, + }, + components: { + loadingIcon, + }, + props: { + authorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + accessLevel: { + type: String, + required: false, + default: '', + }, + reportAbusePath: { + type: String, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + canDelete: { + type: Boolean, + required: true, + }, + resolvable: { + type: Boolean, + required: false, + default: false, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isResolving: { + type: Boolean, + required: false, + default: false, + }, + resolvedBy: { + type: Object, + required: false, + default: () => ({}), + }, + canReportAsAbuse: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters(['getUserDataByProp']), + shouldShowActionsDropdown() { + return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + }, + canAddAwardEmoji() { + return this.currentUserId; + }, + isAuthoredByCurrentUser() { + return this.authorId === this.currentUserId; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + resolveButtonTitle() { + let title = 'Mark as resolved'; - if (this.resolvedBy) { - title = `Resolved by ${this.resolvedBy.name}`; - } + if (this.resolvedBy) { + title = `Resolved by ${this.resolvedBy.name}`; + } - return title; - }, - }, - created() { - this.emojiSmiling = emojiSmiling; - this.emojiSmile = emojiSmile; - this.emojiSmiley = emojiSmiley; - this.editSvg = editSvg; - this.ellipsisSvg = ellipsisSvg; - this.resolveDiscussionSvg = resolveDiscussionSvg; - this.resolvedDiscussionSvg = resolvedDiscussionSvg; - }, - methods: { - onEdit() { - this.$emit('handleEdit'); - }, - onDelete() { - this.$emit('handleDelete'); - }, - onResolve() { - this.$emit('handleResolve'); - }, - }, - }; + return title; + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + this.editSvg = editSvg; + this.ellipsisSvg = ellipsisSvg; + this.resolveDiscussionSvg = resolveDiscussionSvg; + this.resolvedDiscussionSvg = resolvedDiscussionSvg; + }, + methods: { + onEdit() { + this.$emit('handleEdit'); + }, + onDelete() { + this.$emit('handleDelete'); + }, + onResolve() { + this.$emit('handleResolve'); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue index 618b807b9cc..34ecbd00c63 100644 --- a/app/assets/javascripts/notes/components/note_attachment.vue +++ b/app/assets/javascripts/notes/components/note_attachment.vue @@ -1,13 +1,13 @@ <script> - export default { - name: 'NoteAttachment', - props: { - attachment: { - type: Object, - required: true, - }, +export default { + name: 'NoteAttachment', + props: { + attachment: { + type: Object, + required: true, }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index caa9701e03f..6cb8229e268 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,179 +1,192 @@ <script> - import { mapActions, mapGetters } from 'vuex'; - import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; - import emojiSmile from 'icons/_emoji_smile.svg'; - import emojiSmiley from 'icons/_emoji_smiley.svg'; - import Flash from '../../flash'; - import { glEmojiTag } from '../../emoji'; - import tooltip from '../../vue_shared/directives/tooltip'; - - export default { - directives: { - tooltip, +import { mapActions, mapGetters } from 'vuex'; +import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; +import emojiSmile from 'icons/_emoji_smile.svg'; +import emojiSmiley from 'icons/_emoji_smiley.svg'; +import Flash from '../../flash'; +import { glEmojiTag } from '../../emoji'; +import tooltip from '../../vue_shared/directives/tooltip'; + +export default { + directives: { + tooltip, + }, + props: { + awards: { + type: Array, + required: true, }, - props: { - awards: { - type: Array, - required: true, - }, - toggleAwardPath: { - type: String, - required: true, - }, - noteAuthorId: { - type: Number, - required: true, - }, - noteId: { - type: Number, - required: true, - }, + toggleAwardPath: { + type: String, + required: true, }, - computed: { - ...mapGetters([ - 'getUserData', - ]), - // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. - // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] - // This method will group emojis by their name as an Object. See below. - // { - // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], - // bar: [ { name: bar, user: user1 } ] - // } - // We need to do this otherwise we will render the same emoji over and over again. - groupedAwards() { - const awards = this.awards.reduce((acc, award) => { - if (Object.prototype.hasOwnProperty.call(acc, award.name)) { - acc[award.name].push(award); - } else { - Object.assign(acc, { [award.name]: [award] }); - } - - return acc; - }, {}); - - const orderedAwards = {}; - const { thumbsdown, thumbsup } = awards; - // Always show thumbsup and thumbsdown first - if (thumbsup) { - orderedAwards.thumbsup = thumbsup; - delete awards.thumbsup; - } - if (thumbsdown) { - orderedAwards.thumbsdown = thumbsdown; - delete awards.thumbsdown; - } - - return Object.assign({}, orderedAwards, awards); - }, - isAuthoredByMe() { - return this.noteAuthorId === this.getUserData.id; - }, - isLoggedIn() { - return this.getUserData.id; - }, + noteAuthorId: { + type: Number, + required: true, }, - created() { - this.emojiSmiling = emojiSmiling; - this.emojiSmile = emojiSmile; - this.emojiSmiley = emojiSmiley; + noteId: { + type: Number, + required: true, }, - methods: { - ...mapActions([ - 'toggleAwardRequest', - ]), - getAwardHTML(name) { - return glEmojiTag(name); - }, - getAwardClassBindings(awardList, awardName) { - return { - active: this.hasReactionByCurrentUser(awardList), - disabled: !this.canInteractWithEmoji(awardList, awardName), - }; - }, - canInteractWithEmoji(awardList, awardName) { - let isAllowed = true; - const restrictedEmojis = ['thumbsup', 'thumbsdown']; - - // Users can not add :+1: and :-1: to their own notes - if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) { - isAllowed = false; - } - - return this.getUserData.id && isAllowed; - }, - hasReactionByCurrentUser(awardList) { - return awardList.filter(award => award.user.id === this.getUserData.id).length; - }, - awardTitle(awardsList) { - const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); - const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; - let awardList = awardsList; - - // Filter myself from list if I am awarded. - if (hasReactionByCurrentUser) { - awardList = awardList.filter(award => award.user.id !== this.getUserData.id); - } - - // Get only 9-10 usernames to show in tooltip text. - const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); - - // Get the remaining list to use in `and x more` text. - const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); - - // Add myself to the begining of the list so title will start with You. - if (hasReactionByCurrentUser) { - namesToShow.unshift('You'); - } - - let title = ''; - - // We have 10+ awarded user, join them with comma and add `and x more`. - if (remainingAwardList.length) { - title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`; - } else if (namesToShow.length > 1) { - // Join all names with comma but not the last one, it will be added with and text. - title = namesToShow.slice(0, namesToShow.length - 1).join(', '); - // If we have more than 2 users we need an extra comma before and text. - title += namesToShow.length > 2 ? ',' : ''; - title += ` and ${namesToShow.slice(-1)}`; // Append and text - } else { // We have only 2 users so join them with and. - title = namesToShow.join(' and '); - } - - return title; - }, - handleAward(awardName) { - if (!this.isLoggedIn) { - return; - } - - let parsedName; - - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - switch (awardName) { - case '100': - parsedName = 100; - break; - case '1234': - parsedName = 1234; - break; - default: - parsedName = awardName; - break; + }, + computed: { + ...mapGetters(['getUserData']), + // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. + // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] + // This method will group emojis by their name as an Object. See below. + // { + // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], + // bar: [ { name: bar, user: user1 } ] + // } + // We need to do this otherwise we will render the same emoji over and over again. + groupedAwards() { + const awards = this.awards.reduce((acc, award) => { + if (Object.prototype.hasOwnProperty.call(acc, award.name)) { + acc[award.name].push(award); + } else { + Object.assign(acc, { [award.name]: [award] }); } - const data = { - endpoint: this.toggleAwardPath, - noteId: this.noteId, - awardName: parsedName, - }; - - this.toggleAwardRequest(data) - .catch(() => Flash('Something went wrong on our end.')); - }, + return acc; + }, {}); + + const orderedAwards = {}; + const { thumbsdown, thumbsup } = awards; + // Always show thumbsup and thumbsdown first + if (thumbsup) { + orderedAwards.thumbsup = thumbsup; + delete awards.thumbsup; + } + if (thumbsdown) { + orderedAwards.thumbsdown = thumbsdown; + delete awards.thumbsdown; + } + + return Object.assign({}, orderedAwards, awards); + }, + isAuthoredByMe() { + return this.noteAuthorId === this.getUserData.id; + }, + isLoggedIn() { + return this.getUserData.id; + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + }, + methods: { + ...mapActions(['toggleAwardRequest']), + getAwardHTML(name) { + return glEmojiTag(name); + }, + getAwardClassBindings(awardList, awardName) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: !this.canInteractWithEmoji(awardList, awardName), + }; + }, + canInteractWithEmoji(awardList, awardName) { + let isAllowed = true; + const restrictedEmojis = ['thumbsup', 'thumbsdown']; + + // Users can not add :+1: and :-1: to their own notes + if ( + this.getUserData.id === this.noteAuthorId && + restrictedEmojis.indexOf(awardName) > -1 + ) { + isAllowed = false; + } + + return this.getUserData.id && isAllowed; + }, + hasReactionByCurrentUser(awardList) { + return awardList.filter(award => award.user.id === this.getUserData.id) + .length; + }, + awardTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser( + awardsList, + ); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter( + award => award.user.id !== this.getUserData.id, + ); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList + .slice(0, TOOLTIP_NAME_COUNT) + .map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice( + TOOLTIP_NAME_COUNT, + awardList.length, + ); + + // Add myself to the begining of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift('You'); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = `${namesToShow.join(', ')}, and ${ + remainingAwardList.length + } more.`; + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += ` and ${namesToShow.slice(-1)}`; // Append and text + } else { + // We have only 2 users so join them with and. + title = namesToShow.join(' and '); + } + + return title; + }, + handleAward(awardName) { + if (!this.isLoggedIn) { + return; + } + + let parsedName; + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + switch (awardName) { + case '100': + parsedName = 100; + break; + case '1234': + parsedName = 1234; + break; + default: + parsedName = awardName; + break; + } + + const data = { + endpoint: this.toggleAwardPath, + noteId: this.noteId, + awardName: parsedName, + }; + + this.toggleAwardRequest(data).catch(() => + Flash('Something went wrong on our end.'), + ); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index a94f1a28a4c..069f94c5845 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,83 +1,81 @@ <script> - import $ from 'jquery'; - import noteEditedText from './note_edited_text.vue'; - import noteAwardsList from './note_awards_list.vue'; - import noteAttachment from './note_attachment.vue'; - import noteForm from './note_form.vue'; - import TaskList from '../../task_list'; - import autosave from '../mixins/autosave'; +import $ from 'jquery'; +import noteEditedText from './note_edited_text.vue'; +import noteAwardsList from './note_awards_list.vue'; +import noteAttachment from './note_attachment.vue'; +import noteForm from './note_form.vue'; +import TaskList from '../../task_list'; +import autosave from '../mixins/autosave'; - export default { - components: { - noteEditedText, - noteAwardsList, - noteAttachment, - noteForm, +export default { + components: { + noteEditedText, + noteAwardsList, + noteAttachment, + noteForm, + }, + mixins: [autosave], + props: { + note: { + type: Object, + required: true, }, - mixins: [ - autosave, - ], - props: { - note: { - type: Object, - required: true, - }, - canEdit: { - type: Boolean, - required: true, - }, - isEditing: { - type: Boolean, - required: false, - default: false, - }, + canEdit: { + type: Boolean, + required: true, }, - computed: { - noteBody() { - return this.note.note; - }, + isEditing: { + type: Boolean, + required: false, + default: false, }, - mounted() { - this.renderGFM(); - this.initTaskList(); + }, + computed: { + noteBody() { + return this.note.note; + }, + }, + mounted() { + this.renderGFM(); + this.initTaskList(); + + if (this.isEditing) { + this.initAutoSave(this.note.noteable_type); + } + }, + updated() { + this.initTaskList(); + this.renderGFM(); - if (this.isEditing) { + if (this.isEditing) { + if (!this.autosave) { this.initAutoSave(this.note.noteable_type); + } else { + this.setAutoSave(); } + } + }, + methods: { + renderGFM() { + $(this.$refs['note-body']).renderGFM(); }, - updated() { - this.initTaskList(); - this.renderGFM(); - - if (this.isEditing) { - if (!this.autosave) { - this.initAutoSave(this.note.noteable_type); - } else { - this.setAutoSave(); - } + initTaskList() { + if (this.canEdit) { + this.taskList = new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); } }, - methods: { - renderGFM() { - $(this.$refs['note-body']).renderGFM(); - }, - initTaskList() { - if (this.canEdit) { - this.taskList = new TaskList({ - dataType: 'note', - fieldName: 'note', - selector: '.notes', - }); - } - }, - handleFormUpdate(note, parentElement, callback) { - this.$emit('handleFormUpdate', note, parentElement, callback); - }, - formCancelHandler(shouldConfirm, isDirty) { - this.$emit('cancelFormEdition', shouldConfirm, isDirty); - }, + handleFormUpdate(note, parentElement, callback) { + this.$emit('handleFormUpdate', note, parentElement, callback); + }, + formCancelHandler(shouldConfirm, isDirty) { + this.$emit('cancelFormEdition', shouldConfirm, isDirty); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index ae2e52554d2..4ddca918495 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -1,32 +1,32 @@ <script> - import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; - export default { - name: 'EditedNoteText', - components: { - timeAgoTooltip, +export default { + name: 'EditedNoteText', + components: { + timeAgoTooltip, + }, + props: { + actionText: { + type: String, + required: true, }, - props: { - actionText: { - type: String, - required: true, - }, - editedAt: { - type: String, - required: true, - }, - editedBy: { - type: Object, - required: false, - default: () => ({}), - }, - className: { - type: String, - required: false, - default: 'edited-text', - }, + editedAt: { + type: String, + required: true, }, - }; + editedBy: { + type: Object, + required: false, + default: () => ({}), + }, + className: { + type: String, + required: false, + default: 'edited-text', + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 1a13fdbeb7c..c59a2e7a406 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,128 +1,136 @@ <script> - import { mapGetters, mapActions } from 'vuex'; - import eventHub from '../event_hub'; - import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; - import markdownField from '../../vue_shared/components/markdown/field.vue'; - import issuableStateMixin from '../mixins/issuable_state'; - import resolvable from '../mixins/resolvable'; +import { mapGetters, mapActions } from 'vuex'; +import eventHub from '../event_hub'; +import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import markdownField from '../../vue_shared/components/markdown/field.vue'; +import issuableStateMixin from '../mixins/issuable_state'; +import resolvable from '../mixins/resolvable'; - export default { - name: 'IssueNoteForm', - components: { - issueWarning, - markdownField, +export default { + name: 'IssueNoteForm', + components: { + issueWarning, + markdownField, + }, + mixins: [issuableStateMixin, resolvable], + props: { + noteBody: { + type: String, + required: false, + default: '', }, - mixins: [ - issuableStateMixin, - resolvable, - ], - props: { - noteBody: { - type: String, - required: false, - default: '', - }, - noteId: { - type: Number, - required: false, - default: 0, - }, - saveButtonTitle: { - type: String, - required: false, - default: 'Save comment', - }, - note: { - type: Object, - required: false, - default: () => ({}), - }, - isEditing: { - type: Boolean, - required: true, - }, + noteId: { + type: Number, + required: false, + default: 0, }, - data() { - return { - updatedNoteBody: this.noteBody, - conflictWhileEditing: false, - isSubmitting: false, - isResolving: false, - resolveAsThread: true, - }; + saveButtonTitle: { + type: String, + required: false, + default: 'Save comment', }, - computed: { - ...mapGetters([ - 'getDiscussionLastNote', - 'getNoteableData', - 'getNoteableDataByProp', - 'getNotesDataByProp', - 'getUserDataByProp', - ]), - noteHash() { - return `#note_${this.noteId}`; - }, - markdownPreviewPath() { - return this.getNoteableDataByProp('preview_note_path'); - }, - markdownDocsPath() { - return this.getNotesDataByProp('markdownDocsPath'); - }, - quickActionsDocsPath() { - return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; - }, - currentUserId() { - return this.getUserDataByProp('id'); - }, - isDisabled() { - return !this.updatedNoteBody.length || this.isSubmitting; - }, + note: { + type: Object, + required: false, + default: () => ({}), }, - watch: { - noteBody() { - if (this.updatedNoteBody === this.noteBody) { - this.updatedNoteBody = this.noteBody; - } else { - this.conflictWhileEditing = true; - } - }, + isEditing: { + type: Boolean, + required: true, + }, + }, + data() { + return { + updatedNoteBody: this.noteBody, + conflictWhileEditing: false, + isSubmitting: false, + isResolving: false, + resolveAsThread: true, + }; + }, + computed: { + ...mapGetters([ + 'getDiscussionLastNote', + 'getNoteableData', + 'getNoteableDataByProp', + 'getNotesDataByProp', + 'getUserDataByProp', + ]), + noteHash() { + return `#note_${this.noteId}`; + }, + markdownPreviewPath() { + return this.getNoteableDataByProp('preview_note_path'); + }, + markdownDocsPath() { + return this.getNotesDataByProp('markdownDocsPath'); + }, + quickActionsDocsPath() { + return !this.isEditing + ? this.getNotesDataByProp('quickActionsDocsPath') + : undefined; }, - mounted() { - this.$refs.textarea.focus(); + currentUserId() { + return this.getUserDataByProp('id'); }, - methods: { - ...mapActions([ - 'toggleResolveNote', - ]), - handleUpdate(shouldResolve) { - const beforeSubmitDiscussionState = this.discussionResolved; - this.isSubmitting = true; + isDisabled() { + return !this.updatedNoteBody.length || this.isSubmitting; + }, + }, + watch: { + noteBody() { + if (this.updatedNoteBody === this.noteBody) { + this.updatedNoteBody = this.noteBody; + } else { + this.conflictWhileEditing = true; + } + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, + methods: { + ...mapActions(['toggleResolveNote']), + handleUpdate(shouldResolve) { + const beforeSubmitDiscussionState = this.discussionResolved; + this.isSubmitting = true; - this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { + this.$emit( + 'handleFormUpdate', + this.updatedNoteBody, + this.$refs.editNoteForm, + () => { this.isSubmitting = false; if (shouldResolve) { this.resolveHandler(beforeSubmitDiscussionState); } - }); - }, - editMyLastNote() { - if (this.updatedNoteBody === '') { - const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody); + }, + ); + }, + editMyLastNote() { + if (this.updatedNoteBody === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote( + this.updatedNoteBody, + ); - if (lastNoteInDiscussion) { - eventHub.$emit('enterEditMode', { - noteId: lastNoteInDiscussion.id, - }); - } + if (lastNoteInDiscussion) { + eventHub.$emit('enterEditMode', { + noteId: lastNoteInDiscussion.id, + }); } - }, - cancelHandler(shouldConfirm = false) { - // Sends information about confirm message and if the textarea has changed - this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody); - }, + } + }, + cancelHandler(shouldConfirm = false) { + // Sends information about confirm message and if the textarea has changed + this.$emit( + 'cancelFormEdition', + shouldConfirm, + this.noteBody !== this.updatedNoteBody, + ); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 4743d95b951..c3d1ef1fcc6 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,65 +1,63 @@ <script> - import { mapActions } from 'vuex'; - import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import { mapActions } from 'vuex'; +import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; - export default { - components: { - timeAgoTooltip, +export default { + components: { + timeAgoTooltip, + }, + props: { + author: { + type: Object, + required: true, }, - props: { - author: { - type: Object, - required: true, - }, - createdAt: { - type: String, - required: true, - }, - actionText: { - type: String, - required: false, - default: '', - }, - actionTextHtml: { - type: String, - required: false, - default: '', - }, - noteId: { - type: Number, - required: true, - }, - includeToggle: { - type: Boolean, - required: false, - default: false, - }, - expanded: { - type: Boolean, - required: false, - default: true, - }, + createdAt: { + type: String, + required: true, }, - computed: { - toggleChevronClass() { - return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; - }, - noteTimestampLink() { - return `#note_${this.noteId}`; - }, + actionText: { + type: String, + required: false, + default: '', }, - methods: { - ...mapActions([ - 'setTargetNoteHash', - ]), - handleToggle() { - this.$emit('toggleHandler'); - }, - updateTargetNoteHash() { - this.setTargetNoteHash(this.noteTimestampLink); - }, + actionTextHtml: { + type: String, + required: false, + default: '', }, - }; + noteId: { + type: Number, + required: true, + }, + includeToggle: { + type: Boolean, + required: false, + default: false, + }, + expanded: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + toggleChevronClass() { + return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; + }, + noteTimestampLink() { + return `#note_${this.noteId}`; + }, + }, + methods: { + ...mapActions(['setTargetNoteHash']), + handleToggle() { + this.$emit('toggleHandler'); + }, + updateTargetNoteHash() { + this.setTargetNoteHash(this.noteTimestampLink); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 45d3c2de355..91f7c269757 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -1,19 +1,17 @@ <script> - import { mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; - export default { - computed: { - ...mapGetters([ - 'getNotesDataByProp', - ]), - registerLink() { - return this.getNotesDataByProp('registerPath'); - }, - signInLink() { - return this.getNotesDataByProp('newSessionPath'); - }, +export default { + computed: { + ...mapGetters(['getNotesDataByProp']), + registerLink() { + return this.getNotesDataByProp('registerPath'); }, - }; + signInLink() { + return this.getNotesDataByProp('newSessionPath'); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 76bb53eaf2f..cf579c5d4dc 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,210 +1,210 @@ <script> - import { mapActions, mapGetters } from 'vuex'; - import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; - import nextDiscussionsSvg from 'icons/_next_discussion.svg'; - import Flash from '../../flash'; - import { SYSTEM_NOTE } from '../constants'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import noteableNote from './noteable_note.vue'; - import noteHeader from './note_header.vue'; - import noteSignedOutWidget from './note_signed_out_widget.vue'; - import noteEditedText from './note_edited_text.vue'; - import noteForm from './note_form.vue'; - import diffWithNote from './diff_with_note.vue'; - import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; - import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; - import autosave from '../mixins/autosave'; - import noteable from '../mixins/noteable'; - import resolvable from '../mixins/resolvable'; - import tooltip from '../../vue_shared/directives/tooltip'; - import { scrollToElement } from '../../lib/utils/common_utils'; +import { mapActions, mapGetters } from 'vuex'; +import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; +import nextDiscussionsSvg from 'icons/_next_discussion.svg'; +import Flash from '../../flash'; +import { SYSTEM_NOTE } from '../constants'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteableNote from './noteable_note.vue'; +import noteHeader from './note_header.vue'; +import noteSignedOutWidget from './note_signed_out_widget.vue'; +import noteEditedText from './note_edited_text.vue'; +import noteForm from './note_form.vue'; +import diffWithNote from './diff_with_note.vue'; +import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; +import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import autosave from '../mixins/autosave'; +import noteable from '../mixins/noteable'; +import resolvable from '../mixins/resolvable'; +import tooltip from '../../vue_shared/directives/tooltip'; +import { scrollToElement } from '../../lib/utils/common_utils'; - export default { - components: { - noteableNote, - diffWithNote, - userAvatarLink, - noteHeader, - noteSignedOutWidget, - noteEditedText, - noteForm, - placeholderNote, - placeholderSystemNote, +export default { + components: { + noteableNote, + diffWithNote, + userAvatarLink, + noteHeader, + noteSignedOutWidget, + noteEditedText, + noteForm, + placeholderNote, + placeholderSystemNote, + }, + directives: { + tooltip, + }, + mixins: [autosave, noteable, resolvable], + props: { + note: { + type: Object, + required: true, }, - directives: { - tooltip, - }, - mixins: [ - autosave, - noteable, - resolvable, - ], - props: { - note: { - type: Object, - required: true, - }, - }, - data() { + }, + data() { + return { + isReplying: false, + isResolving: false, + resolveAsThread: true, + }; + }, + computed: { + ...mapGetters([ + 'getNoteableData', + 'discussionCount', + 'resolvedDiscussionCount', + 'unresolvedDiscussions', + ]), + discussion() { return { - isReplying: false, - isResolving: false, - resolveAsThread: true, + ...this.note.notes[0], + truncatedDiffLines: this.note.truncated_diff_lines, + diffFile: this.note.diff_file, + diffDiscussion: this.note.diff_discussion, + imageDiffHtml: this.note.image_diff_html, }; }, - computed: { - ...mapGetters([ - 'getNoteableData', - 'discussionCount', - 'resolvedDiscussionCount', - 'unresolvedDiscussions', - ]), - discussion() { - return { - ...this.note.notes[0], - truncatedDiffLines: this.note.truncated_diff_lines, - diffFile: this.note.diff_file, - diffDiscussion: this.note.diff_discussion, - imageDiffHtml: this.note.image_diff_html, - }; - }, - author() { - return this.discussion.author; - }, - canReply() { - return this.getNoteableData.current_user.can_create_note; - }, - newNotePath() { - return this.getNoteableData.create_note_path; - }, - lastUpdatedBy() { - const { notes } = this.note; + author() { + return this.discussion.author; + }, + canReply() { + return this.getNoteableData.current_user.can_create_note; + }, + newNotePath() { + return this.getNoteableData.create_note_path; + }, + lastUpdatedBy() { + const { notes } = this.note; - if (notes.length > 1) { - return notes[notes.length - 1].author; - } + if (notes.length > 1) { + return notes[notes.length - 1].author; + } - return null; - }, - lastUpdatedAt() { - const { notes } = this.note; + return null; + }, + lastUpdatedAt() { + const { notes } = this.note; - if (notes.length > 1) { - return notes[notes.length - 1].created_at; - } + if (notes.length > 1) { + return notes[notes.length - 1].created_at; + } - return null; - }, - hasUnresolvedDiscussion() { - return this.unresolvedDiscussions.length > 0; - }, - wrapperComponent() { - return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div'; - }, - wrapperClass() { - return this.isDiffDiscussion ? '' : 'panel panel-default'; - }, + return null; + }, + hasUnresolvedDiscussion() { + return this.unresolvedDiscussions.length > 0; + }, + wrapperComponent() { + return this.discussion.diffDiscussion && this.discussion.diffFile + ? diffWithNote + : 'div'; }, - mounted() { - if (this.isReplying) { + wrapperClass() { + return this.isDiffDiscussion ? '' : 'panel panel-default'; + }, + }, + mounted() { + if (this.isReplying) { + this.initAutoSave(this.discussion.noteable_type); + } + }, + updated() { + if (this.isReplying) { + if (!this.autosave) { this.initAutoSave(this.discussion.noteable_type); + } else { + this.setAutoSave(); } - }, - updated() { - if (this.isReplying) { - if (!this.autosave) { - this.initAutoSave(this.discussion.noteable_type); - } else { - this.setAutoSave(); + } + }, + created() { + this.resolveDiscussionsSvg = resolveDiscussionsSvg; + this.nextDiscussionsSvg = nextDiscussionsSvg; + }, + methods: { + ...mapActions([ + 'saveNote', + 'toggleDiscussion', + 'removePlaceholderNotes', + 'toggleResolveNote', + ]), + componentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === SYSTEM_NOTE) { + return placeholderSystemNote; } + return placeholderNote; } + + return noteableNote; }, - created() { - this.resolveDiscussionsSvg = resolveDiscussionsSvg; - this.nextDiscussionsSvg = nextDiscussionsSvg; + componentData(note) { + return note.isPlaceholderNote ? this.note.notes[0] : note; }, - methods: { - ...mapActions([ - 'saveNote', - 'toggleDiscussion', - 'removePlaceholderNotes', - 'toggleResolveNote', - ]), - componentName(note) { - if (note.isPlaceholderNote) { - if (note.placeholderType === SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.note.id }); + }, + showReplyForm() { + this.isReplying = true; + }, + cancelReplyForm(shouldConfirm) { + if (shouldConfirm && this.$refs.noteForm.isDirty) { + const msg = 'Are you sure you want to cancel creating this comment?'; - return noteableNote; - }, - componentData(note) { - return note.isPlaceholderNote ? this.note.notes[0] : note; - }, - toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.note.id }); - }, - showReplyForm() { - this.isReplying = true; - }, - cancelReplyForm(shouldConfirm) { - if (shouldConfirm && this.$refs.noteForm.isDirty) { - // eslint-disable-next-line no-alert - if (!confirm('Are you sure you want to cancel creating this comment?')) { - return; - } + // eslint-disable-next-line no-alert + if (!confirm(msg)) { + return; } + } - this.resetAutoSave(); - this.isReplying = false; - }, - saveReply(noteText, form, callback) { - const replyData = { - endpoint: this.newNotePath, - flashContainer: this.$el, - data: { - in_reply_to_discussion_id: this.note.reply_id, - target_type: this.noteableType, - target_id: this.discussion.noteable_id, - note: { note: noteText }, - }, - }; - this.isReplying = false; + this.resetAutoSave(); + this.isReplying = false; + }, + saveReply(noteText, form, callback) { + const replyData = { + endpoint: this.newNotePath, + flashContainer: this.$el, + data: { + in_reply_to_discussion_id: this.note.reply_id, + target_type: this.noteableType, + target_id: this.discussion.noteable_id, + note: { note: noteText }, + }, + }; + this.isReplying = false; - this.saveNote(replyData) - .then(() => { - this.resetAutoSave(); - callback(); - }) - .catch((err) => { - this.removePlaceholderNotes(); - this.isReplying = true; - this.$nextTick(() => { - const msg = `Your comment could not be submitted! + this.saveNote(replyData) + .then(() => { + this.resetAutoSave(); + callback(); + }) + .catch(err => { + this.removePlaceholderNotes(); + this.isReplying = true; + this.$nextTick(() => { + const msg = `Your comment could not be submitted! Please check your network connection and try again.`; - Flash(msg, 'alert', this.$el); - this.$refs.noteForm.note = noteText; - callback(err); - }); + Flash(msg, 'alert', this.$el); + this.$refs.noteForm.note = noteText; + callback(err); }); - }, - jumpToDiscussion() { - const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); - const index = unresolvedIds.indexOf(this.note.id); + }); + }, + jumpToDiscussion() { + const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); + const index = unresolvedIds.indexOf(this.note.id); - if (index >= 0 && index !== unresolvedIds.length) { - const nextId = unresolvedIds[index + 1]; - const el = document.querySelector(`[data-discussion-id="${nextId}"]`); + if (index >= 0 && index !== unresolvedIds.length) { + const nextId = unresolvedIds[index + 1]; + const el = document.querySelector(`[data-discussion-id="${nextId}"]`); - if (el) { - scrollToElement(el); - } + if (el) { + scrollToElement(el); } - }, + } }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 6d5501d7d98..3554027d2b4 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,152 +1,152 @@ <script> - import $ from 'jquery'; - import { mapGetters, mapActions } from 'vuex'; - import { escape } from 'underscore'; - import Flash from '../../flash'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import noteHeader from './note_header.vue'; - import noteActions from './note_actions.vue'; - import noteBody from './note_body.vue'; - import eventHub from '../event_hub'; - import noteable from '../mixins/noteable'; - import resolvable from '../mixins/resolvable'; +import $ from 'jquery'; +import { mapGetters, mapActions } from 'vuex'; +import { escape } from 'underscore'; +import Flash from '../../flash'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteHeader from './note_header.vue'; +import noteActions from './note_actions.vue'; +import noteBody from './note_body.vue'; +import eventHub from '../event_hub'; +import noteable from '../mixins/noteable'; +import resolvable from '../mixins/resolvable'; - export default { - components: { - userAvatarLink, - noteHeader, - noteActions, - noteBody, +export default { + components: { + userAvatarLink, + noteHeader, + noteActions, + noteBody, + }, + mixins: [noteable, resolvable], + props: { + note: { + type: Object, + required: true, }, - mixins: [ - noteable, - resolvable, - ], - props: { - note: { - type: Object, - required: true, - }, + }, + data() { + return { + isEditing: false, + isDeleting: false, + isRequesting: false, + isResolving: false, + }; + }, + computed: { + ...mapGetters(['targetNoteHash', 'getUserData']), + author() { + return this.note.author; }, - data() { + classNameBindings() { return { - isEditing: false, - isDeleting: false, - isRequesting: false, - isResolving: false, + 'is-editing': this.isEditing && !this.isRequesting, + 'is-requesting being-posted': this.isRequesting, + 'disabled-content': this.isDeleting, + target: this.targetNoteHash === this.noteAnchorId, }; }, - computed: { - ...mapGetters([ - 'targetNoteHash', - 'getUserData', - ]), - author() { - return this.note.author; - }, - classNameBindings() { - return { - 'is-editing': this.isEditing && !this.isRequesting, - 'is-requesting being-posted': this.isRequesting, - 'disabled-content': this.isDeleting, - target: this.targetNoteHash === this.noteAnchorId, - }; - }, - canReportAsAbuse() { - return this.note.report_abuse_path && this.author.id !== this.getUserData.id; - }, - noteAnchorId() { - return `note_${this.note.id}`; - }, + canReportAsAbuse() { + return ( + this.note.report_abuse_path && this.author.id !== this.getUserData.id + ); }, - - created() { - eventHub.$on('enterEditMode', ({ noteId }) => { - if (noteId === this.note.id) { - this.isEditing = true; - this.scrollToNoteIfNeeded($(this.$el)); - } - }); + noteAnchorId() { + return `note_${this.note.id}`; }, + }, - methods: { - ...mapActions([ - 'deleteNote', - 'updateNote', - 'toggleResolveNote', - 'scrollToNoteIfNeeded', - ]), - editHandler() { + created() { + eventHub.$on('enterEditMode', ({ noteId }) => { + if (noteId === this.note.id) { this.isEditing = true; - }, - deleteHandler() { - // eslint-disable-next-line no-alert - if (confirm('Are you sure you want to delete this comment?')) { - this.isDeleting = true; + this.scrollToNoteIfNeeded($(this.$el)); + } + }); + }, - this.deleteNote(this.note) - .then(() => { - this.isDeleting = false; - }) - .catch(() => { - Flash('Something went wrong while deleting your note. Please try again.'); - this.isDeleting = false; - }); - } - }, - formUpdateHandler(noteText, parentElement, callback) { - const data = { - endpoint: this.note.path, - note: { - target_type: this.noteableType, - target_id: this.note.noteable_id, - note: { note: noteText }, - }, - }; - this.isRequesting = true; - this.oldContent = this.note.note_html; - this.note.note_html = escape(noteText); + methods: { + ...mapActions([ + 'deleteNote', + 'updateNote', + 'toggleResolveNote', + 'scrollToNoteIfNeeded', + ]), + editHandler() { + this.isEditing = true; + }, + deleteHandler() { + // eslint-disable-next-line no-alert + if (confirm('Are you sure you want to delete this comment?')) { + this.isDeleting = true; - this.updateNote(data) + this.deleteNote(this.note) .then(() => { - this.isEditing = false; - this.isRequesting = false; - this.oldContent = null; - $(this.$refs.noteBody.$el).renderGFM(); - this.$refs.noteBody.resetAutoSave(); - callback(); + this.isDeleting = false; }) .catch(() => { - this.isRequesting = false; - this.isEditing = true; - this.$nextTick(() => { - const msg = 'Something went wrong while editing your comment. Please try again.'; - Flash(msg, 'alert', this.$el); - this.recoverNoteContent(noteText); - callback(); - }); + Flash( + 'Something went wrong while deleting your note. Please try again.', + ); + this.isDeleting = false; }); - }, - formCancelHandler(shouldConfirm, isDirty) { - if (shouldConfirm && isDirty) { - // eslint-disable-next-line no-alert - if (!confirm('Are you sure you want to cancel editing this comment?')) return; - } - this.$refs.noteBody.resetAutoSave(); - if (this.oldContent) { - this.note.note_html = this.oldContent; + } + }, + formUpdateHandler(noteText, parentElement, callback) { + const data = { + endpoint: this.note.path, + note: { + target_type: this.noteableType, + target_id: this.note.noteable_id, + note: { note: noteText }, + }, + }; + this.isRequesting = true; + this.oldContent = this.note.note_html; + this.note.note_html = escape(noteText); + + this.updateNote(data) + .then(() => { + this.isEditing = false; + this.isRequesting = false; this.oldContent = null; - } - this.isEditing = false; - }, - recoverNoteContent(noteText) { - // we need to do this to prevent noteForm inconsistent content warning - // this is something we intentionally do so we need to recover the content - this.note.note = noteText; - this.$refs.noteBody.$refs.noteForm.note.note = noteText; - }, + $(this.$refs.noteBody.$el).renderGFM(); + this.$refs.noteBody.resetAutoSave(); + callback(); + }) + .catch(() => { + this.isRequesting = false; + this.isEditing = true; + this.$nextTick(() => { + const msg = + 'Something went wrong while editing your comment. Please try again.'; + Flash(msg, 'alert', this.$el); + this.recoverNoteContent(noteText); + callback(); + }); + }); + }, + formCancelHandler(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel editing this comment?')) + return; + } + this.$refs.noteBody.resetAutoSave(); + if (this.oldContent) { + this.note.note_html = this.oldContent; + this.oldContent = null; + } + this.isEditing = false; + }, + recoverNoteContent(noteText) { + // we need to do this to prevent noteForm inconsistent content warning + // this is something we intentionally do so we need to recover the content + this.note.note = noteText; + this.$refs.noteBody.$refs.noteForm.note.note = noteText; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c97472c879c..a90c6d6381d 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,160 +1,162 @@ <script> - import $ from 'jquery'; - import { mapGetters, mapActions } from 'vuex'; - import { getLocationHash } from '../../lib/utils/url_utility'; - import Flash from '../../flash'; - import store from '../stores/'; - import * as constants from '../constants'; - import noteableNote from './noteable_note.vue'; - import noteableDiscussion from './noteable_discussion.vue'; - import systemNote from '../../vue_shared/components/notes/system_note.vue'; - import commentForm from './comment_form.vue'; - import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; - import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; +import $ from 'jquery'; +import { mapGetters, mapActions } from 'vuex'; +import { getLocationHash } from '../../lib/utils/url_utility'; +import Flash from '../../flash'; +import store from '../stores/'; +import * as constants from '../constants'; +import noteableNote from './noteable_note.vue'; +import noteableDiscussion from './noteable_discussion.vue'; +import systemNote from '../../vue_shared/components/notes/system_note.vue'; +import commentForm from './comment_form.vue'; +import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; +import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; - export default { - name: 'NotesApp', - components: { - noteableNote, - noteableDiscussion, - systemNote, - commentForm, - loadingIcon, - placeholderNote, - placeholderSystemNote, +export default { + name: 'NotesApp', + components: { + noteableNote, + noteableDiscussion, + systemNote, + commentForm, + loadingIcon, + placeholderNote, + placeholderSystemNote, + }, + props: { + noteableData: { + type: Object, + required: true, }, - props: { - noteableData: { - type: Object, - required: true, - }, - notesData: { - type: Object, - required: true, - }, - userData: { - type: Object, - required: false, - default: () => ({}), - }, + notesData: { + type: Object, + required: true, }, - store, - data() { - return { - isLoading: true, - }; + userData: { + type: Object, + required: false, + default: () => ({}), }, - computed: { - ...mapGetters([ - 'notes', - 'getNotesDataByProp', - 'discussionCount', - ]), - noteableType() { - // FIXME -- @fatihacet Get this from JSON data. - const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; + }, + store, + data() { + return { + isLoading: true, + }; + }, + computed: { + ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), + noteableType() { + // FIXME -- @fatihacet Get this from JSON data. + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; - return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE; - }, - allNotes() { - if (this.isLoading) { - const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; - - return new Array(totalNotes).fill({ - isSkeletonNote: true, - }); - } - return this.notes; - }, - }, - created() { - this.setNotesData(this.notesData); - this.setNoteableData(this.noteableData); - this.setUserData(this.userData); + return this.noteableData.merge_params + ? MERGE_REQUEST_NOTEABLE_TYPE + : ISSUE_NOTEABLE_TYPE; }, - mounted() { - this.fetchNotes(); + allNotes() { + if (this.isLoading) { + const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; - const parentElement = this.$el.parentElement; - - if (parentElement && - parentElement.classList.contains('js-vue-notes-event')) { - parentElement.addEventListener('toggleAward', (event) => { - const { awardName, noteId } = event.detail; - this.actionToggleAward({ awardName, noteId }); + return new Array(totalNotes).fill({ + isSkeletonNote: true, }); } - document.addEventListener('refreshVueNotes', this.fetchNotes); - }, - beforeDestroy() { - document.removeEventListener('refreshVueNotes', this.fetchNotes); + return this.notes; }, - methods: { - ...mapActions({ - actionFetchNotes: 'fetchNotes', - poll: 'poll', - actionToggleAward: 'toggleAward', - scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', - setNotesData: 'setNotesData', - setNoteableData: 'setNoteableData', - setUserData: 'setUserData', - setLastFetchedAt: 'setLastFetchedAt', - setTargetNoteHash: 'setTargetNoteHash', - }), - getComponentName(note) { - if (note.isSkeletonNote) { - return skeletonLoadingContainer; - } - if (note.isPlaceholderNote) { - if (note.placeholderType === constants.SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } else if (note.individual_note) { - return note.notes[0].system ? systemNote : noteableNote; - } + }, + created() { + this.setNotesData(this.notesData); + this.setNoteableData(this.noteableData); + this.setUserData(this.userData); + }, + mounted() { + this.fetchNotes(); + + const parentElement = this.$el.parentElement; - return noteableDiscussion; - }, - getComponentData(note) { - return note.individual_note ? note.notes[0] : note; - }, - fetchNotes() { - return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) - .then(() => this.initPolling()) - .then(() => { - this.isLoading = false; - }) - .then(() => this.$nextTick()) - .then(() => this.checkLocationHash()) - .catch(() => { - this.isLoading = false; - Flash('Something went wrong while fetching comments. Please try again.'); - }); - }, - initPolling() { - if (this.isPollingInitialized) { - return; + if ( + parentElement && + parentElement.classList.contains('js-vue-notes-event') + ) { + parentElement.addEventListener('toggleAward', event => { + const { awardName, noteId } = event.detail; + this.actionToggleAward({ awardName, noteId }); + }); + } + document.addEventListener('refreshVueNotes', this.fetchNotes); + }, + beforeDestroy() { + document.removeEventListener('refreshVueNotes', this.fetchNotes); + }, + methods: { + ...mapActions({ + actionFetchNotes: 'fetchNotes', + poll: 'poll', + actionToggleAward: 'toggleAward', + scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', + setNotesData: 'setNotesData', + setNoteableData: 'setNoteableData', + setUserData: 'setUserData', + setLastFetchedAt: 'setLastFetchedAt', + setTargetNoteHash: 'setTargetNoteHash', + }), + getComponentName(note) { + if (note.isSkeletonNote) { + return skeletonLoadingContainer; + } + if (note.isPlaceholderNote) { + if (note.placeholderType === constants.SYSTEM_NOTE) { + return placeholderSystemNote; } + return placeholderNote; + } else if (note.individual_note) { + return note.notes[0].system ? systemNote : noteableNote; + } - this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); + return noteableDiscussion; + }, + getComponentData(note) { + return note.individual_note ? note.notes[0] : note; + }, + fetchNotes() { + return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) + .then(() => this.initPolling()) + .then(() => { + this.isLoading = false; + }) + .then(() => this.$nextTick()) + .then(() => this.checkLocationHash()) + .catch(() => { + this.isLoading = false; + Flash( + 'Something went wrong while fetching comments. Please try again.', + ); + }); + }, + initPolling() { + if (this.isPollingInitialized) { + return; + } - this.poll(); - this.isPollingInitialized = true; - }, - checkLocationHash() { - const hash = getLocationHash(); - const element = document.getElementById(hash); + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); - if (hash && element) { - this.setTargetNoteHash(hash); - this.scrollToNoteIfNeeded($(element)); - } - }, + this.poll(); + this.isPollingInitialized = true; + }, + checkLocationHash() { + const hash = getLocationHash(); + const element = document.getElementById(hash); + + if (hash && element) { + this.setTargetNoteHash(hash); + this.scrollToNoteIfNeeded($(element)); + } }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 545bf2c99a7..f90775d0157 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,35 +1,43 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: '#js-vue-notes', - components: { - notesApp, - }, - data() { - const notesDataset = document.getElementById('js-vue-notes').dataset; - const parsedUserData = JSON.parse(notesDataset.currentUserData); - const currentUserData = parsedUserData ? { - id: parsedUserData.id, - name: parsedUserData.name, - username: parsedUserData.username, - avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, - path: parsedUserData.path, - } : {}; +document.addEventListener( + 'DOMContentLoaded', + () => + new Vue({ + el: '#js-vue-notes', + components: { + notesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + let currentUserData = {}; + + if (parsedUserData) { + currentUserData = { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, + }; + } - return { - noteableData: JSON.parse(notesDataset.noteableData), - currentUserData, - notesData: JSON.parse(notesDataset.notesData), - }; - }, - render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, + return { + noteableData: JSON.parse(notesDataset.noteableData), + currentUserData, + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); }, - }); - }, -})); + }), +); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 837a4029346..3dff715905f 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -5,7 +5,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { initAutoSave(noteableType) { - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]); + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ + 'Note', + capitalizeFirstCharacter(noteableType), + this.note.id, + ]); }, resetAutoSave() { this.autosave.reset(); diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index ab1ae115e52..f79049b85f6 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -12,7 +12,8 @@ export default { discussionResolved() { const { notes, resolved } = this.note; - if (notes) { // Decide resolved state using store. Only valid for discussions. + if (notes) { + // Decide resolved state using store. Only valid for discussions. return notes.every(note => note.resolved && !note.system); } @@ -26,7 +27,9 @@ export default { return __('Comment and resolve discussion'); } - return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); + return this.discussionResolved + ? __('Unresolve discussion') + : __('Resolve discussion'); }, }, methods: { @@ -42,7 +45,9 @@ export default { }) .catch(() => { this.isResolving = false; - const msg = __('Something went wrong while resolving this discussion. Please try again.'); + const msg = __( + 'Something went wrong while resolving this discussion. Please try again.', + ); Flash(msg, 'alert', this.$el); }); }, diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index b4c19a9ec22..7c623aac6ed 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -22,7 +22,9 @@ export default { }, toggleResolveNote(endpoint, isResolved) { const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; - const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; + const method = isResolved + ? UNRESOLVE_NOTE_METHOD_NAME + : RESOLVE_NOTE_METHOD_NAME; return Vue.http[method](endpoint); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index dc0e3c39775..244a6980b5a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,86 +12,115 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; let eTagPoll; -export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); -export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data); -export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); -export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); -export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data); -export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); -export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); - -export const fetchNotes = ({ commit }, path) => service - .fetchNotes(path) - .then(res => res.json()) - .then((res) => { - commit(types.SET_INITIAL_NOTES, res); - }); +export const setNotesData = ({ commit }, data) => + commit(types.SET_NOTES_DATA, data); +export const setNoteableData = ({ commit }, data) => + commit(types.SET_NOTEABLE_DATA, data); +export const setUserData = ({ commit }, data) => + commit(types.SET_USER_DATA, data); +export const setLastFetchedAt = ({ commit }, data) => + commit(types.SET_LAST_FETCHED_AT, data); +export const setInitialNotes = ({ commit }, data) => + commit(types.SET_INITIAL_NOTES, data); +export const setTargetNoteHash = ({ commit }, data) => + commit(types.SET_TARGET_NOTE_HASH, data); +export const toggleDiscussion = ({ commit }, data) => + commit(types.TOGGLE_DISCUSSION, data); + +export const fetchNotes = ({ commit }, path) => + service + .fetchNotes(path) + .then(res => res.json()) + .then(res => { + commit(types.SET_INITIAL_NOTES, res); + }); -export const deleteNote = ({ commit }, note) => service - .deleteNote(note.path) - .then(() => { +export const deleteNote = ({ commit }, note) => + service.deleteNote(note.path).then(() => { commit(types.DELETE_NOTE, note); }); -export const updateNote = ({ commit }, { endpoint, note }) => service - .updateNote(endpoint, note) - .then(res => res.json()) - .then((res) => { - commit(types.UPDATE_NOTE, res); - }); +export const updateNote = ({ commit }, { endpoint, note }) => + service + .updateNote(endpoint, note) + .then(res => res.json()) + .then(res => { + commit(types.UPDATE_NOTE, res); + }); -export const replyToDiscussion = ({ commit }, { endpoint, data }) => service - .replyToDiscussion(endpoint, data) - .then(res => res.json()) - .then((res) => { - commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); +export const replyToDiscussion = ({ commit }, { endpoint, data }) => + service + .replyToDiscussion(endpoint, data) + .then(res => res.json()) + .then(res => { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); - return res; - }); + return res; + }); -export const createNewNote = ({ commit }, { endpoint, data }) => service - .createNewNote(endpoint, data) - .then(res => res.json()) - .then((res) => { - if (!res.errors) { - commit(types.ADD_NEW_NOTE, res); - } - return res; - }); +export const createNewNote = ({ commit }, { endpoint, data }) => + service + .createNewNote(endpoint, data) + .then(res => res.json()) + .then(res => { + if (!res.errors) { + commit(types.ADD_NEW_NOTE, res); + } + return res; + }); export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); -export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service - .toggleResolveNote(endpoint, isResolved) - .then(res => res.json()) - .then((res) => { - const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; +export const toggleResolveNote = ( + { commit }, + { endpoint, isResolved, discussion }, +) => + service + .toggleResolveNote(endpoint, isResolved) + .then(res => res.json()) + .then(res => { + const mutationType = discussion + ? types.UPDATE_DISCUSSION + : types.UPDATE_NOTE; - commit(mutationType, res); - }); + commit(mutationType, res); + }); -export const closeIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.closePath) - .then(res => res.json()) - .then((data) => { - commit(types.CLOSE_ISSUE); - dispatch('emitStateChangedEvent', data); - }); +export const closeIssue = ({ commit, dispatch, state }) => { + dispatch('toggleStateButtonLoading', true); + return service + .toggleIssueState(state.notesData.closePath) + .then(res => res.json()) + .then(data => { + commit(types.CLOSE_ISSUE); + dispatch('emitStateChangedEvent', data); + dispatch('toggleStateButtonLoading', false); + }); +}; -export const reopenIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.reopenPath) - .then(res => res.json()) - .then((data) => { - commit(types.REOPEN_ISSUE); - dispatch('emitStateChangedEvent', data); - }); +export const reopenIssue = ({ commit, dispatch, state }) => { + dispatch('toggleStateButtonLoading', true); + return service + .toggleIssueState(state.notesData.reopenPath) + .then(res => res.json()) + .then(data => { + commit(types.REOPEN_ISSUE); + dispatch('emitStateChangedEvent', data); + dispatch('toggleStateButtonLoading', false); + }); +}; + +export const toggleStateButtonLoading = ({ commit }, value) => + commit(types.TOGGLE_STATE_BUTTON_LOADING, value); export const emitStateChangedEvent = ({ commit, getters }, data) => { - const event = new CustomEvent('issuable_vue_app:change', { detail: { - data, - isClosed: getters.openState === constants.CLOSED, - } }); + const event = new CustomEvent('issuable_vue_app:change', { + detail: { + data, + isClosed: getters.openState === constants.CLOSED, + }, + }); document.dispatchEvent(event); }; @@ -133,59 +162,70 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }); } - return dispatch(methodToDispatch, noteData) - .then((res) => { - const { errors } = res; - const commandsChanges = res.commands_changes; + return dispatch(methodToDispatch, noteData).then(res => { + const { errors } = res; + const commandsChanges = res.commands_changes; - if (hasQuickActions && errors && Object.keys(errors).length) { - eTagPoll.makeRequest(); + if (hasQuickActions && errors && Object.keys(errors).length) { + eTagPoll.makeRequest(); - $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - Flash('Commands applied', 'notice', noteData.flashContainer); - } - - if (commandsChanges) { - if (commandsChanges.emoji_award) { - const votesBlock = $('.js-awards-block').eq(0); - - loadAwardsHandler() - .then((awardsHandler) => { - awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award); - awardsHandler.scrollToAwards(); - }) - .catch(() => { - Flash( - 'Something went wrong while adding your award. Please try again.', - 'alert', - noteData.flashContainer, - ); - }); - } + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + Flash('Commands applied', 'notice', noteData.flashContainer); + } - if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { - sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); - } + if (commandsChanges) { + if (commandsChanges.emoji_award) { + const votesBlock = $('.js-awards-block').eq(0); + + loadAwardsHandler() + .then(awardsHandler => { + awardsHandler.addAwardToEmojiBar( + votesBlock, + commandsChanges.emoji_award, + ); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + Flash( + 'Something went wrong while adding your award. Please try again.', + 'alert', + noteData.flashContainer, + ); + }); } - if (errors && errors.commands_only) { - Flash(errors.commands_only, 'notice', noteData.flashContainer); + if ( + commandsChanges.spend_time != null || + commandsChanges.time_estimate != null + ) { + sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); } - commit(types.REMOVE_PLACEHOLDER_NOTES); + } - return res; - }); + if (errors && errors.commands_only) { + Flash(errors.commands_only, 'notice', noteData.flashContainer); + } + commit(types.REMOVE_PLACEHOLDER_NOTES); + + return res; + }); }; const pollSuccessCallBack = (resp, commit, state, getters) => { if (resp.notes && resp.notes.length) { const { notesById } = getters; - resp.notes.forEach((note) => { + resp.notes.forEach(note => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { - const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + } else if ( + note.type === constants.DISCUSSION_NOTE || + note.type === constants.DIFF_NOTE + ) { + const discussion = utils.findNoteObjectById( + state.notes, + note.discussion_id, + ); if (discussion) { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); @@ -208,9 +248,12 @@ export const poll = ({ commit, state, getters }) => { resource: service, method: 'poll', data: state, - successCallback: resp => resp.json() - .then(data => pollSuccessCallBack(data, commit, state, getters)), - errorCallback: () => Flash('Something went wrong while fetching latest comments.'), + successCallback: resp => + resp + .json() + .then(data => pollSuccessCallBack(data, commit, state, getters)), + errorCallback: () => + Flash('Something went wrong while fetching latest comments.'), }); if (!Visibility.hidden()) { @@ -237,15 +280,22 @@ export const restartPolling = () => { }; export const fetchData = ({ commit, state, getters }) => { - const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + const requestData = { + endpoint: state.notesData.notesPath, + lastFetchedAt: state.lastFetchedAt, + }; - service.poll(requestData) + service + .poll(requestData) .then(resp => resp.json) .then(data => pollSuccessCallBack(data, commit, state, getters)) .catch(() => Flash('Something went wrong while fetching latest comments.')); }; -export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => { +export const toggleAward = ( + { commit, state, getters, dispatch }, + { awardName, noteId }, +) => { commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] }); }; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index e6180101c58..f89591a54d6 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -11,27 +11,31 @@ export const getNoteableDataByProp = state => prop => state.noteableData[prop]; export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; -export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; +export const getUserDataByProp = state => prop => + state.userData && state.userData[prop]; -export const notesById = state => state.notes.reduce((acc, note) => { - note.notes.every(n => Object.assign(acc, { [n.id]: n })); - return acc; -}, {}); +export const notesById = state => + state.notes.reduce((acc, note) => { + note.notes.every(n => Object.assign(acc, { [n.id]: n })); + return acc; + }, {}); const reverseNotes = array => array.slice(0).reverse(); -const isLastNote = (note, state) => !note.system && - state.userData && note.author && +const isLastNote = (note, state) => + !note.system && + state.userData && + note.author && note.author.id === state.userData.id; -export const getCurrentUserLastNote = state => _.flatten( - reverseNotes(state.notes) - .map(note => reverseNotes(note.notes)), +export const getCurrentUserLastNote = state => + _.flatten( + reverseNotes(state.notes).map(note => reverseNotes(note.notes)), ).find(el => isLastNote(el, state)); -export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) - .find(el => isLastNote(el, state)); +export const getDiscussionLastNote = state => discussion => + reverseNotes(discussion.notes).find(el => isLastNote(el, state)); -export const discussionCount = (state) => { +export const discussionCount = state => { const discussions = state.notes.filter(n => !n.individual_note); return discussions.length; @@ -43,10 +47,10 @@ export const unresolvedDiscussions = (state, getters) => { return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); }; -export const resolvedDiscussionsById = (state) => { +export const resolvedDiscussionsById = state => { const map = {}; - state.notes.forEach((n) => { + state.notes.forEach(n => { if (n.notes) { const resolved = n.notes.every(note => note.resolved && !note.system); diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index 488a9ca38d3..9ed19bf171e 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -12,6 +12,9 @@ export default new Vuex.Store({ targetNoteHash: null, lastFetchedAt: null, + // View layer + isToggleStateButtonLoading: false, + // holds endpoints and permissions provided through haml notesData: {}, userData: {}, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index da1b5a9e51a..b455e23ecde 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -17,3 +17,4 @@ export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; +export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 949628a65c0..c8edc06349f 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -7,7 +7,7 @@ export default { [types.ADD_NEW_NOTE](state, note) { const { discussion_id, type } = note; const [exists] = state.notes.filter(n => n.id === note.discussion_id); - const isDiscussion = (type === constants.DISCUSSION_NOTE); + const isDiscussion = type === constants.DISCUSSION_NOTE; if (!exists) { const noteData = { @@ -63,13 +63,15 @@ export default { const note = notes[i]; const children = note.notes; - if (children.length && !note.individual_note) { // remove placeholder from discussions + if (children.length && !note.individual_note) { + // remove placeholder from discussions for (let j = children.length - 1; j >= 0; j -= 1) { if (children[j].isPlaceholderNote) { children.splice(j, 1); } } - } else if (note.isPlaceholderNote) { // remove placeholders from state root + } else if (note.isPlaceholderNote) { + // remove placeholders from state root notes.splice(i, 1); } } @@ -89,10 +91,10 @@ export default { [types.SET_INITIAL_NOTES](state, notesData) { const notes = []; - notesData.forEach((note) => { + notesData.forEach(note => { // To support legacy notes, should be very rare case. if (note.individual_note && note.notes.length > 1) { - note.notes.forEach((n) => { + note.notes.forEach(n => { notes.push({ ...note, notes: [n], // override notes array to only have one item to mimick individual_note @@ -103,7 +105,7 @@ export default { notes.push({ ...note, - expanded: (oldNote ? oldNote.expanded : note.expanded), + expanded: oldNote ? oldNote.expanded : note.expanded, }); } }); @@ -128,7 +130,9 @@ export default { notesArr.push({ individual_note: true, isPlaceholderNote: true, - placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, + placeholderType: data.isSystemNote + ? constants.SYSTEM_NOTE + : constants.NOTE, notes: [ { body: data.noteBody, @@ -141,12 +145,16 @@ export default { const { awardName, note } = data; const { id, name, username } = state.userData; - const hasEmojiAwardedByCurrentUser = note.award_emoji - .filter(emoji => emoji.name === data.awardName && emoji.user.id === id); + const hasEmojiAwardedByCurrentUser = note.award_emoji.filter( + emoji => emoji.name === data.awardName && emoji.user.id === id, + ); if (hasEmojiAwardedByCurrentUser.length) { // If current user has awarded this emoji, remove it. - note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); + note.award_emoji.splice( + note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), + 1, + ); } else { note.award_emoji.push({ name: awardName, @@ -199,4 +207,8 @@ export default { [types.REOPEN_ISSUE](state) { Object.assign(state.noteableData, { state: constants.REOPENED }); }, + + [types.TOGGLE_STATE_BUTTON_LOADING](state, value) { + Object.assign(state, { isToggleStateButtonLoading: value }); + }, }; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 275263a2aaa..a0e096ebfaf 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -2,13 +2,15 @@ import AjaxCache from '~/lib/utils/ajax_cache'; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; -export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; +export const findNoteObjectById = (notes, id) => + notes.filter(n => n.id === id)[0]; -export const getQuickActionText = (note) => { +export const getQuickActionText = note => { let text = 'Applying command'; - const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; + const quickActions = + AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; - const executedCommands = quickActions.filter((command) => { + const executedCommands = quickActions.filter(command => { const commandRegex = new RegExp(`/${command.name}`); return commandRegex.test(note); }); @@ -27,4 +29,5 @@ export const getQuickActionText = (note) => { export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); -export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); +export const stripQuickActions = note => + note.replace(REGEX_QUICK_ACTIONS, '').trim(); diff --git a/app/assets/javascripts/pages/ci/lints/create/index.js b/app/assets/javascripts/pages/ci/lints/new/index.js index 8e8a843da0b..8e8a843da0b 100644 --- a/app/assets/javascripts/pages/ci/lints/create/index.js +++ b/app/assets/javascripts/pages/ci/lints/new/index.js diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js new file mode 100644 index 00000000000..2e24a10fa5c --- /dev/null +++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js @@ -0,0 +1,7 @@ +import NotificationsForm from '../../../../notifications_form'; +import notificationsDropdown from '../../../../notifications_dropdown'; + +document.addEventListener('DOMContentLoaded', () => { + new NotificationsForm(); // eslint-disable-line no-new + notificationsDropdown(); +}); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 26cbb279d4a..85c6862d629 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,7 +1,29 @@ +import Vue from 'vue'; +import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobViewer from '~/blob/viewer/index'; import initBlob from '~/pages/projects/init_blob'; document.addEventListener('DOMContentLoaded', () => { new BlobViewer(); // eslint-disable-line no-new initBlob(); + + const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); + const statusLink = document.querySelector('.commit-actions .ci-status-link'); + if (statusLink) { + statusLink.remove(); + // eslint-disable-next-line no-new + new Vue({ + el: CommitPipelineStatusEl, + components: { + commitPipelineStatus, + }, + render(createElement) { + return createElement('commit-pipeline-status', { + props: { + endpoint: CommitPipelineStatusEl.dataset.endpoint, + }, + }); + }, + }); + } }); diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index a6a172402d8..3b0f0f960b8 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; import UserCallout from '~/user_callout'; @@ -19,10 +20,22 @@ document.addEventListener('DOMContentLoaded', () => { className: 'js-autodevops-banner', }); - if ($('#tree-slider').length) new TreeView(); // eslint-disable-line no-new - if ($('.blob-viewer').length) new BlobViewer(); // eslint-disable-line no-new - if ($('.project-show-activity').length) new Activities(); // eslint-disable-line no-new - $('#tree-slider').waitForImages(() => { + // Project show page loads different overview content based on user preferences + const treeSlider = document.querySelector('#tree-slider'); + if (treeSlider) { + new TreeView(); // eslint-disable-line no-new + initBlob(); + } + + if (document.querySelector('.blob-viewer')) { + new BlobViewer(); // eslint-disable-line no-new + } + + if (document.querySelector('.project-show-activity')) { + new Activities(); // eslint-disable-line no-new + } + + $(treeSlider).waitForImages(() => { ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); }); }); diff --git a/app/assets/javascripts/performance_bar.js b/app/assets/javascripts/performance_bar.js index ef44e2323ef..c22598ee665 100644 --- a/app/assets/javascripts/performance_bar.js +++ b/app/assets/javascripts/performance_bar.js @@ -14,8 +14,6 @@ export default class PerformanceBar { init(opts) { const $container = $(opts.container); - this.$sqlProfileLink = $container.find('.js-toggle-modal-peek-sql'); - this.$sqlProfileModal = $container.find('#modal-peek-pg-queries'); this.$lineProfileLink = $container.find('.js-toggle-modal-peek-line-profile'); this.$lineProfileModal = $('#modal-peek-line-profile'); this.initEventListeners(); @@ -23,7 +21,6 @@ export default class PerformanceBar { } initEventListeners() { - this.$sqlProfileLink.on('click', () => this.handleSQLProfileLink()); this.$lineProfileLink.on('click', e => this.handleLineProfileLink(e)); $(document).on('click', '.js-lineprof-file', PerformanceBar.toggleLineProfileFile); } @@ -36,10 +33,6 @@ export default class PerformanceBar { } } - handleSQLProfileLink() { - PerformanceBar.toggleModal(this.$sqlProfileModal); - } - handleLineProfileLink(e) { const lineProfilerParameter = getParameterValues('lineprofiler'); const lineProfilerParameterRegex = new RegExp(`lineprofiler=${lineProfilerParameter[0]}`); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue new file mode 100644 index 00000000000..7bef2e97349 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -0,0 +1,144 @@ +<script> +import timeagoMixin from '../../vue_shared/mixins/timeago'; +import tooltip from '../../vue_shared/directives/tooltip'; +import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import { visitUrl } from '../../lib/utils/url_utility'; +import createFlash from '../../flash'; +import MemoryUsage from './memory_usage.vue'; +import StatusIcon from './mr_widget_status_icon.vue'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'Deployment', + components: { + LoadingButton, + MemoryUsage, + StatusIcon, + }, + directives: { + tooltip, + }, + mixins: [ + timeagoMixin, + ], + props: { + deployment: { + type: Object, + required: true, + }, + }, + data() { + return { + isStopping: false, + }; + }, + computed: { + deployTimeago() { + return this.timeFormated(this.deployment.deployed_at); + }, + hasExternalUrls() { + return !!(this.deployment.external_url && this.deployment.external_url_formatted); + }, + hasDeploymentTime() { + return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + }, + hasDeploymentMeta() { + return !!(this.deployment.url && this.deployment.name); + }, + hasMetrics() { + return !!(this.deployment.metrics_url); + }, + }, + methods: { + stopEnvironment() { + const msg = 'Are you sure you want to stop this environment?'; + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + this.isStopping = true; + + MRWidgetService.stopEnvironment(this.deployment.stop_url) + .then(res => res.data) + .then((data) => { + if (data.redirect_url) { + visitUrl(data.redirect_url); + } + + this.isStopping = false; + }) + .catch(() => { + createFlash('Something went wrong while stopping this environment. Please try again.'); + this.isStopping = false; + }); + } + }, + }, +}; +</script> + +<template> + <div class="mr-widget-heading deploy-heading"> + <div class="ci-widget media"> + <div class="ci-status-icon ci-status-icon-success"> + <span class="js-icon-link icon-link"> + <status-icon status="success" /> + </span> + </div> + <div class="media-body"> + <div class="deploy-body"> + <template v-if="hasDeploymentMeta"> + <span> + Deployed to + </span> + <a + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-meta" + > + {{ deployment.name }} + </a> + </template> + <template v-if="hasExternalUrls"> + <span> + on + </span> + <a + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-url" + > + <i + class="fa fa-external-link" + aria-hidden="true" + > + </i> + {{ deployment.external_url_formatted }} + </a> + </template> + <span + v-if="hasDeploymentTime" + v-tooltip + :title="deployment.deployed_at_formatted" + class="js-deploy-time" + > + {{ deployTimeago }} + </span> + <loading-button + v-if="deployment.stop_url" + container-class="btn btn-default btn-xs prepend-left-default" + label="Stop environment" + :loading="isStopping" + @click="stopEnvironment" + /> + </div> + <memory-usage + v-if="hasMetrics" + :metrics-url="deployment.metrics_url" + :metrics-monitoring-url="deployment.metrics_monitoring_url" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js deleted file mode 100644 index c7f992384c8..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js +++ /dev/null @@ -1,113 +0,0 @@ -import { getTimeago } from '~/lib/utils/datetime_utility'; -import { visitUrl } from '../../lib/utils/url_utility'; -import Flash from '../../flash'; -import MemoryUsage from './memory_usage.vue'; -import StatusIcon from './mr_widget_status_icon.vue'; -import MRWidgetService from '../services/mr_widget_service'; - -export default { - name: 'MRWidgetDeployment', - props: { - mr: { type: Object, required: true }, - service: { type: Object, required: true }, - }, - components: { - MemoryUsage, - StatusIcon, - }, - methods: { - formatDate(date) { - return getTimeago().format(date); - }, - hasExternalUrls(deployment = {}) { - return deployment.external_url && deployment.external_url_formatted; - }, - hasDeploymentTime(deployment = {}) { - return deployment.deployed_at && deployment.deployed_at_formatted; - }, - hasDeploymentMeta(deployment = {}) { - return deployment.url && deployment.name; - }, - stopEnvironment(deployment) { - const msg = 'Are you sure you want to stop this environment?'; - const isConfirmed = confirm(msg); // eslint-disable-line - - if (isConfirmed) { - MRWidgetService.stopEnvironment(deployment.stop_url) - .then(res => res.data) - .then((data) => { - if (data.redirect_url) { - visitUrl(data.redirect_url); - } - }) - .catch(() => { - new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line - }); - } - }, - }, - template: ` - <div class="mr-widget-heading deploy-heading"> - <div v-for="deployment in mr.deployments"> - <div class="ci-widget media"> - <div class="ci-status-icon ci-status-icon-success"> - <span class="js-icon-link icon-link"> - <status-icon status="success" /> - </span> - </div> - <div class="media-body space-children"> - <span> - <span - v-if="hasDeploymentMeta(deployment)"> - Deployed to - </span> - <a - v-if="hasDeploymentMeta(deployment)" - :href="deployment.url" - target="_blank" - rel="noopener noreferrer nofollow" - class="js-deploy-meta inline"> - {{deployment.name}} - </a> - <span - v-if="hasExternalUrls(deployment)"> - on - </span> - <a - v-if="hasExternalUrls(deployment)" - :href="deployment.external_url" - target="_blank" - rel="noopener noreferrer nofollow" - class="js-deploy-url inline"> - <i - class="fa fa-external-link" - aria-hidden="true" /> - {{deployment.external_url_formatted}} - </a> - <span - v-if="hasDeploymentTime(deployment)" - :data-title="deployment.deployed_at_formatted" - class="js-deploy-time" - data-toggle="tooltip" - data-placement="top"> - {{formatDate(deployment.deployed_at)}} - </span> - </span> - <button - type="button" - v-if="deployment.stop_url" - @click="stopEnvironment(deployment)" - class="btn btn-default btn-xs"> - Stop environment - </button> - <memory-usage - v-if="deployment.metrics_url" - :metrics-url="deployment.metrics_url" - :metrics-monitoring-url="deployment.metrics_monitoring_url" - /> - </div> - </div> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js deleted file mode 100644 index 67b271c69ca..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js +++ /dev/null @@ -1,27 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetUnresolvedDiscussions', - props: { - mr: { type: Object, required: true }, - }, - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - There are unresolved discussions. Please resolve these discussions - </span> - <a - v-if="mr.createIssueToResolveDiscussionsPath" - :href="mr.createIssueToResolveDiscussionsPath" - class="btn btn-default btn-xs js-create-issue"> - Create an issue to resolve them later - </a> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue new file mode 100644 index 00000000000..9ade6a91747 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -0,0 +1,33 @@ +<script> +import statusIcon from '../mr_widget_status_icon.vue'; + +export default { + name: 'UnresolvedDiscussions', + components: { + statusIcon, + }, + props: { + mr: { type: Object, required: true }, + }, +}; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + There are unresolved discussions. Please resolve these discussions + </span> + <a + v-if="mr.createIssueToResolveDiscussionsPath" + :href="mr.createIssueToResolveDiscussionsPath" + class="btn btn-default btn-xs js-create-issue"> + Create an issue to resolve them later + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 20624aad0ad..021c2237661 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -14,7 +14,7 @@ export { default as SmartInterval } from '~/smart_interval'; export { default as WidgetHeader } from './components/mr_widget_header.vue'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; -export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as Deployment } from './components/deployment.vue'; export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; export { default as MergedState } from './components/states/mr_widget_merged.vue'; @@ -29,7 +29,7 @@ export { default as MissingBranchState } from './components/states/mr_widget_mis 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 UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; +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'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; 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 cc8bc6af1e1..169adfe0a1d 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 @@ -5,7 +5,7 @@ import { WidgetHeader, WidgetMergeHelp, WidgetPipeline, - WidgetDeployment, + Deployment, WidgetMaintainerEdit, WidgetRelatedLinks, MergedState, @@ -67,9 +67,6 @@ export default { shouldRenderRelatedLinks() { return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; }, - shouldRenderDeployments() { - return this.mr.deployments.length; - }, shouldRenderSourceBranchRemovalStatus() { return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch && (!this.mr.isNothingToMergeState && !this.mr.isMergedState); @@ -216,7 +213,7 @@ export default { 'mr-widget-header': WidgetHeader, 'mr-widget-merge-help': WidgetMergeHelp, 'mr-widget-pipeline': WidgetPipeline, - 'mr-widget-deployment': WidgetDeployment, + Deployment, 'mr-widget-maintainer-edit': WidgetMaintainerEdit, 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, @@ -250,10 +247,11 @@ export default { :ci-status="mr.ciStatus" :has-ci="mr.hasCI" /> - <mr-widget-deployment - v-if="shouldRenderDeployments" - :mr="mr" - :service="service" /> + <deployment + v-for="deployment in mr.deployments" + :key="deployment.id" + :deployment="deployment" + /> <div class="mr-widget-section"> <component :is="componentName" 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/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index ddd9dbb2be4..e12b5aab381 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -17,8 +17,6 @@ */ @mixin markdown-table { width: auto; - display: block; - overflow-x: auto; } /* 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; } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 8b680c2dc52..b487f6278c2 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -194,8 +194,6 @@ .commit-actions { @media (min-width: $screen-sm-min) { - font-size: 0; - .fa-spinner { font-size: 12px; } @@ -204,7 +202,7 @@ .ci-status-link { display: inline-block; position: relative; - top: 1px; + top: 2px; } .btn-clipboard, @@ -226,7 +224,7 @@ .ci-status-icon { position: relative; - top: 1px; + top: 2px; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index f887a11004f..4692d0fb873 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -718,6 +718,8 @@ } .mr-memory-usage { + width: 100%; + p.usage-info-loading .usage-info-load-spinner { margin-right: 10px; font-size: 16px; @@ -727,3 +729,36 @@ .fork-sprite { margin-right: -5px; } + +.deploy-heading { + .media-body { + min-width: 0; + } +} + +.deploy-body { + display: flex; + flex-wrap: wrap; + + @media (min-width: $screen-xs) { + flex-wrap: nowrap; + white-space: nowrap; + } + + > *:not(:last-child) { + margin-right: .3em; + } +} + +.deploy-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 100px; + max-width: 150px; + + @media (min-width: $screen-xs) { + min-width: 0; + max-width: 100%; + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 3c565837383..085a2e74328 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -16,7 +16,7 @@ ul.notes { .note-created-ago, .note-updated-at { - white-space: nowrap; + white-space: normal; } .discussion-body { diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index e70a57c2a67..9a0ec936979 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -180,6 +180,11 @@ ul.wiki-pages-list.content-list { } } +.wiki-holder { + overflow-x: auto; + overflow-y: hidden; +} + .wiki { table { @include markdown-table; diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index ee507009e50..cba9a53dc4b 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -19,6 +19,12 @@ class Projects::DiscussionsController < Projects::ApplicationController render_discussion end + def show + render json: { + discussion_html: view_to_html_string('discussions/_diff_with_notes', discussion: discussion, expanded: true) + } + end + private def render_discussion diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index d6bcd939522..5c507fe8d50 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -16,8 +16,7 @@ class Admin::ProjectsFinder items = by_archived(items) items = by_personal(items) items = by_name(items) - items = sort(items) - items.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page]) + sort(items).page(params[:page]) end private diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb index d5e77c7e271..cd4075b340d 100644 --- a/app/helpers/javascript_helper.rb +++ b/app/helpers/javascript_helper.rb @@ -2,9 +2,4 @@ module JavascriptHelper def page_specific_javascript_tag(js) javascript_include_tag asset_path(js) end - - # deprecated; use webpack_bundle_tag directly instead - def page_specific_javascript_bundle_tag(bundle) - webpack_bundle_tag(bundle) - end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 0dee6df525d..3cbbf8b5dfa 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -347,15 +347,15 @@ class ApplicationSetting < ActiveRecord::Base end def home_page_url_column_exists? - ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url) + ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url) end def help_page_support_url_column_exists? - ActiveRecord::Base.connection.column_exists?(:application_settings, :help_page_support_url) + ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url) end def sidekiq_throttling_column_exists? - ActiveRecord::Base.connection.column_exists?(:application_settings, :sidekiq_throttling_enabled) + ::Gitlab::Database.cached_column_exists?(:application_settings, :sidekiq_throttling_enabled) end def domain_whitelist_raw diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index b230b7f47ef..c1da2081465 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -41,12 +41,12 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } - scope :with_artifacts, ->() do + scope :with_artifacts_archive, ->() do where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', - '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id')) + '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) end - scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } - scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } + scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } + scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :ref_protected, -> { where(protected: true) } @@ -252,23 +252,23 @@ module Ci # All variables, including those dependent on environment, which could # contain unexpanded variables. def variables(environment: persisted_environment) - variables = predefined_variables - variables += project.predefined_variables - variables += pipeline.predefined_variables - variables += runner.predefined_variables if runner - variables += project.container_registry_variables - variables += project.deployment_variables if has_environment? - variables += project.auto_devops_variables - variables += yaml_variables - variables += user_variables - variables += project.group.secret_variables_for(ref, project).map(&:to_runner_variable) if project.group - variables += secret_variables(environment: environment) - variables += trigger_request.user_variables if trigger_request - variables += pipeline.variables.map(&:to_runner_variable) - variables += pipeline.pipeline_schedule.job_variables if pipeline.pipeline_schedule - variables += persisted_environment_variables if environment - - variables + collection = Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.concat(predefined_variables) + variables.concat(project.predefined_variables) + variables.concat(pipeline.predefined_variables) + variables.concat(runner.predefined_variables) if runner + variables.concat(project.deployment_variables(environment: environment)) if has_environment? + variables.concat(yaml_variables) + variables.concat(user_variables) + variables.concat(project.group.secret_variables_for(ref, project)) if project.group + variables.concat(secret_variables(environment: environment)) + variables.concat(trigger_request.user_variables) if trigger_request + variables.concat(pipeline.variables) + variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule + variables.concat(persisted_environment_variables) if environment + end + + collection.to_runner_variables end def features @@ -430,14 +430,14 @@ module Ci end def user_variables - return [] if user.blank? + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables if user.blank? - [ - { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, - { key: 'GITLAB_USER_EMAIL', value: user.email, public: true }, - { key: 'GITLAB_USER_LOGIN', value: user.username, public: true }, - { key: 'GITLAB_USER_NAME', value: user.name, public: true } - ] + variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) + variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) + variables.append(key: 'GITLAB_USER_LOGIN', value: user.username) + variables.append(key: 'GITLAB_USER_NAME', value: user.name) + end end def secret_variables(environment: persisted_environment) @@ -540,60 +540,57 @@ module Ci CI_REGISTRY_USER = 'gitlab-ci-token'.freeze def predefined_variables - variables = [ - { key: 'CI', value: 'true', public: true }, - { key: 'GITLAB_CI', value: 'true', public: true }, - { key: 'GITLAB_FEATURES', value: project.namespace.features.join(','), public: true }, - { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, - { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, - { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, - { key: 'CI_JOB_ID', value: id.to_s, public: true }, - { key: 'CI_JOB_NAME', value: name, public: true }, - { key: 'CI_JOB_STAGE', value: stage, public: true }, - { key: 'CI_JOB_TOKEN', value: token, public: false }, - { key: 'CI_COMMIT_SHA', value: sha, public: true }, - { key: 'CI_COMMIT_REF_NAME', value: ref, public: true }, - { key: 'CI_COMMIT_REF_SLUG', value: ref_slug, public: true }, - { key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER, public: true }, - { key: 'CI_REGISTRY_PASSWORD', value: token, public: false }, - { key: 'CI_REPOSITORY_URL', value: repo_url, public: false } - ] - - variables << { key: "CI_COMMIT_TAG", value: ref, public: true } if tag? - variables << { key: "CI_PIPELINE_TRIGGERED", value: 'true', public: true } if trigger_request - variables << { key: "CI_JOB_MANUAL", value: 'true', public: true } if action? - variables.concat(legacy_variables) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI', value: 'true') + variables.append(key: 'GITLAB_CI', value: 'true') + variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(',')) + variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') + variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) + variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION) + variables.append(key: 'CI_JOB_ID', value: id.to_s) + variables.append(key: 'CI_JOB_NAME', value: name) + variables.append(key: 'CI_JOB_STAGE', value: stage) + variables.append(key: 'CI_JOB_TOKEN', value: token, public: false) + variables.append(key: 'CI_COMMIT_SHA', value: sha) + variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + variables.append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false) + variables.append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false) + variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? + variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? + variables.concat(legacy_variables) + end end def persisted_environment_variables - return [] unless persisted_environment + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless persisted_environment - variables = persisted_environment.predefined_variables + variables.concat(persisted_environment.predefined_variables) - # Here we're passing unexpanded environment_url for runner to expand, - # and we need to make sure that CI_ENVIRONMENT_NAME and - # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. - variables << { key: 'CI_ENVIRONMENT_URL', value: environment_url, public: true } if environment_url - - variables + # Here we're passing unexpanded environment_url for runner to expand, + # and we need to make sure that CI_ENVIRONMENT_NAME and + # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. + variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url + end end def legacy_variables - variables = [ - { key: 'CI_BUILD_ID', value: id.to_s, public: true }, - { key: 'CI_BUILD_TOKEN', value: token, public: false }, - { key: 'CI_BUILD_REF', value: sha, public: true }, - { key: 'CI_BUILD_BEFORE_SHA', value: before_sha, public: true }, - { key: 'CI_BUILD_REF_NAME', value: ref, public: true }, - { key: 'CI_BUILD_REF_SLUG', value: ref_slug, public: true }, - { key: 'CI_BUILD_NAME', value: name, public: true }, - { key: 'CI_BUILD_STAGE', value: stage, public: true } - ] - - variables << { key: "CI_BUILD_TAG", value: ref, public: true } if tag? - variables << { key: "CI_BUILD_TRIGGERED", value: 'true', public: true } if trigger_request - variables << { key: "CI_BUILD_MANUAL", value: 'true', public: true } if action? - variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_BUILD_ID', value: id.to_s) + variables.append(key: 'CI_BUILD_TOKEN', value: token, public: false) + variables.append(key: 'CI_BUILD_REF', value: sha) + variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_BUILD_REF_NAME', value: ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_BUILD_NAME', value: name) + variables.append(key: 'CI_BUILD_STAGE', value: stage) + variables.append(key: "CI_BUILD_TAG", value: ref) if tag? + variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? + end end def environment_url diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a72a815bfe8..f2edcdd61fd 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -473,11 +473,10 @@ module Ci end def predefined_variables - [ - { key: 'CI_PIPELINE_ID', value: id.to_s, public: true }, - { key: 'CI_CONFIG_PATH', value: ci_yaml_file_path, public: true }, - { key: 'CI_PIPELINE_SOURCE', value: source.to_s, public: true } - ] + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_PIPELINE_ID', value: id.to_s) + .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) end def queued_duration @@ -514,7 +513,7 @@ module Ci # We purposely cast the builds to an Array here. Because we always use the # rows if there are more than 0 this prevents us from having to run two # queries: one to get the count and one to get the rows. - @latest_builds_with_artifacts ||= builds.latest.with_artifacts.to_a + @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a end private diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 609620a62bb..7173f88f1c7 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -132,11 +132,10 @@ module Ci end def predefined_variables - [ - { key: 'CI_RUNNER_ID', value: id.to_s, public: true }, - { key: 'CI_RUNNER_DESCRIPTION', value: description, public: true }, - { key: 'CI_RUNNER_TAGS', value: tag_list.to_s, public: true } - ] + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_RUNNER_ID', value: id.to_s) + .append(key: 'CI_RUNNER_DESCRIPTION', value: description) + .append(key: 'CI_RUNNER_TAGS', value: tag_list.to_s) end def tick_runner_queue diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 7ce8befeeeb..ba6552f238f 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -56,19 +56,19 @@ module Clusters def predefined_variables config = YAML.dump(kubeconfig) - variables = [ - { key: 'KUBE_URL', value: api_url, public: true }, - { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }, - { key: 'KUBECONFIG', value: config, public: false, file: true } - ] - - if ca_pem.present? - variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } - variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables + .append(key: 'KUBE_URL', value: api_url) + .append(key: 'KUBE_TOKEN', value: token, public: false) + .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBECONFIG', value: config, public: false, file: true) + + if ca_pem.present? + variables + .append(key: 'KUBE_CA_PEM', value: ca_pem) + .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) + end end - - variables end # Constructs a list of terminals from the reactive cache @@ -134,7 +134,7 @@ module Clusters kubeclient = build_kubeclient! kubeclient.get_pods(namespace: actual_namespace).as_json - rescue KubeException => err + rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 [] diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 67a988addbe..f05e606995d 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -7,29 +7,24 @@ module Storage raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry') end - expires_full_path_cache - - # Move the namespace directory in all storage paths used by member projects - repository_storage_paths.each do |repository_storage_path| - # Ensure old directory exists before moving it - gitlab_shell.add_namespace(repository_storage_path, full_path_was) - - # Ensure new directory exists before moving it (if there's a parent) - gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent + parent_was = if parent_changed? && parent_id_was.present? + Namespace.find(parent_id_was) # raise NotFound early if needed + end - unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path) + expires_full_path_cache - Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}" + move_repositories - # if we cannot move namespace directory we should rollback - # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') - end + if parent_changed? + former_parent_full_path = parent_was&.full_path + parent_full_path = parent&.full_path + Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) + Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) + else + Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path) + Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path) end - Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path) - Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path) - remove_exports! # If repositories moved successfully we need to @@ -57,6 +52,26 @@ module Storage private + def move_repositories + # Move the namespace directory in all storage paths used by member projects + repository_storage_paths.each do |repository_storage_path| + # Ensure old directory exists before moving it + gitlab_shell.add_namespace(repository_storage_path, full_path_was) + + # Ensure new directory exists before moving it (if there's a parent) + gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent + + unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path) + + Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}" + + # if we cannot move namespace directory we should rollback + # db changes in order to prevent out of sync between db and fs + raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + end + end + end + def old_repository_storage_paths @old_repository_storage_paths ||= repository_storage_paths end diff --git a/app/models/environment.rb b/app/models/environment.rb index 2b0a88ac5b4..9517723d9d9 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -65,10 +65,9 @@ class Environment < ActiveRecord::Base end def predefined_variables - [ - { key: 'CI_ENVIRONMENT_NAME', value: name, public: true }, - { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true } - ] + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_ENVIRONMENT_NAME', value: name) + .append(key: 'CI_ENVIRONMENT_SLUG', value: slug) end def recently_updated_on_branch?(ref) diff --git a/app/models/member.rb b/app/models/member.rb index 36090676051..ec8156bbb01 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -55,7 +55,7 @@ class Member < ActiveRecord::Base scope :active_without_invites, -> do left_join_users .where(users: { state: 'active' }) - .where(requested_at: nil) + .non_request .reorder(nil) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c2bae379a94..149ef7ec429 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -579,9 +579,10 @@ class MergeRequest < ActiveRecord::Base return unless open? old_diff_refs = self.diff_refs + new_diff = create_merge_request_diff + + MergeRequests::MergeRequestDiffCacheService.new.execute(self, new_diff) - create_merge_request_diff - MergeRequests::MergeRequestDiffCacheService.new.execute(self) new_diff_refs = self.diff_refs update_diff_discussion_positions( diff --git a/app/models/project.rb b/app/models/project.rb index 5f9d9785d64..d6e663f14e4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -542,7 +542,7 @@ class Project < ActiveRecord::Base latest_pipeline = pipelines.latest_successful_for(ref) if latest_pipeline - latest_pipeline.builds.latest.with_artifacts + latest_pipeline.builds.latest.with_artifacts_archive else builds.none end @@ -1083,7 +1083,7 @@ class Project < ActiveRecord::Base # Forked import is handled asynchronously return if forked? && !force - if gitlab_shell.add_repository(repository_storage, disk_path) + if gitlab_shell.create_repository(repository_storage, disk_path) repository.after_create true else @@ -1519,8 +1519,8 @@ class Project < ActiveRecord::Base @errors = original_errors end - def add_export_job(current_user:) - job_id = ProjectExportWorker.perform_async(current_user.id, self.id) + def add_export_job(current_user:, params: {}) + job_id = ProjectExportWorker.perform_async(current_user.id, self.id, params) if job_id Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}" @@ -1572,29 +1572,30 @@ class Project < ActiveRecord::Base end def predefined_variables - [ - { key: 'CI_PROJECT_ID', value: id.to_s, public: true }, - { key: 'CI_PROJECT_NAME', value: path, public: true }, - { key: 'CI_PROJECT_PATH', value: full_path, public: true }, - { key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug, public: true }, - { key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true }, - { key: 'CI_PROJECT_URL', value: web_url, public: true }, - { key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level), public: true } - ] + visibility = Gitlab::VisibilityLevel.string_level(visibility_level) + + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_PROJECT_ID', value: id.to_s) + .append(key: 'CI_PROJECT_NAME', value: path) + .append(key: 'CI_PROJECT_PATH', value: full_path) + .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) + .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) + .append(key: 'CI_PROJECT_URL', value: web_url) + .append(key: 'CI_PROJECT_VISIBILITY', value: visibility) + .concat(container_registry_variables) + .concat(auto_devops_variables) end def container_registry_variables - return [] unless Gitlab.config.registry.enabled + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless Gitlab.config.registry.enabled - variables = [ - { key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port, public: true } - ] + variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port) - if container_registry_enabled? - variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true } + if container_registry_enabled? + variables.append(key: 'CI_REGISTRY_IMAGE', value: container_registry_url) + end end - - variables end def secret_variables_for(ref:, environment: nil) @@ -1614,16 +1615,14 @@ class Project < ActiveRecord::Base end end - def deployment_variables - return [] unless deployment_platform - - deployment_platform.predefined_variables + def deployment_variables(environment: nil) + deployment_platform(environment: environment)&.predefined_variables || [] end def auto_devops_variables return [] unless auto_devops_enabled? - (auto_devops || build_auto_devops)&.variables + (auto_devops || build_auto_devops)&.predefined_variables end def append_or_update_attribute(name, value) diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 112ed7ed434..ed6c1eddbc1 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -14,9 +14,12 @@ class ProjectAutoDevops < ActiveRecord::Base domain.present? || instance_domain.present? end - def variables - variables = [] - variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain.presence || instance_domain, public: true } if has_domain? - variables + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + if has_domain? + variables.append(key: 'AUTO_DEVOPS_DOMAIN', + value: domain.presence || instance_domain) + end + end end end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index e5035c81df0..601a6a077f5 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -161,11 +161,6 @@ class JiraService < IssueTrackerService add_comment(data, jira_issue) end - # reason why service cannot be tested - def disabled_title - "Please fill in Password and Username." - end - def test(_) result = test_settings success = result.present? diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index ad4ad7903ad..20fed432e55 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -105,19 +105,19 @@ class KubernetesService < DeploymentService def predefined_variables config = YAML.dump(kubeconfig) - variables = [ - { key: 'KUBE_URL', value: api_url, public: true }, - { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }, - { key: 'KUBECONFIG', value: config, public: false, file: true } - ] - - if ca_pem.present? - variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } - variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables + .append(key: 'KUBE_URL', value: api_url) + .append(key: 'KUBE_TOKEN', value: token, public: false) + .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBECONFIG', value: config, public: false, file: true) + + if ca_pem.present? + variables + .append(key: 'KUBE_CA_PEM', value: ca_pem) + .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) + end end - - variables end # Constructs a list of terminals from the reactive cache @@ -197,7 +197,7 @@ class KubernetesService < DeploymentService kubeclient = build_kubeclient! kubeclient.get_pods(namespace: actual_namespace).as_json - rescue KubeException => err + rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 [] diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 9c7b58dead5..4cf149ac044 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -39,10 +39,6 @@ class PipelinesEmailService < Service project.pipelines.any? end - def disabled_title - 'Please setup a pipeline on your repository.' - end - def test_data(project, user) data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last) data[:user] = user.hook_attrs diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index f6041da986c..52e067cb44c 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -169,7 +169,7 @@ class ProjectWiki private def create_repo!(raw_repository) - gitlab_shell.add_repository(project.repository_storage, disk_path) + gitlab_shell.create_repository(project.repository_storage, disk_path) raise CouldNotCreateWikiError unless raw_repository.exists? diff --git a/app/models/service.rb b/app/models/service.rb index 99bf757ae44..2556db68146 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -162,11 +162,6 @@ class Service < ActiveRecord::Base true end - # reason why service cannot be tested - def disabled_title - "Please setup a project repository." - end - # Provide convenient accessor methods # for each serialized property. # Also keep track of updated properties in a similar way as ActiveModel::Dirty diff --git a/app/services/ci/fetch_kubernetes_token_service.rb b/app/services/ci/fetch_kubernetes_token_service.rb index e73c6ad6780..bca883ec0a0 100644 --- a/app/services/ci/fetch_kubernetes_token_service.rb +++ b/app/services/ci/fetch_kubernetes_token_service.rb @@ -32,7 +32,7 @@ module Ci kubeclient = build_kubeclient! kubeclient.get_secrets.as_json - rescue KubeException => err + rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 [] diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index bde090eaeec..90393e951a4 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -12,7 +12,7 @@ module Clusters else check_timeout end - rescue KubeException => ke + rescue Kubeclient::HttpError => ke app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored? end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb index 8ceeec687cd..4c25a09814b 100644 --- a/app/services/clusters/applications/install_service.rb +++ b/app/services/clusters/applications/install_service.rb @@ -10,7 +10,7 @@ module Clusters ClusterWaitForAppInstallationWorker.perform_in( ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue KubeException => ke + rescue Kubeclient::HttpError => ke app.make_errored!("Kubernetes error: #{ke.message}") rescue StandardError app.make_errored!("Can't start installation process") diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb index 46acdc5406c..a954564946b 100644 --- a/app/services/files/create_service.rb +++ b/app/services/files/create_service.rb @@ -1,11 +1,11 @@ module Files class CreateService < Files::BaseService def create_commit! - handler = Lfs::FileModificationHandler.new(project, @branch_name) + transformer = Lfs::FileTransformer.new(project, @branch_name) - handler.new_file(@file_path, @file_content) do |content_or_lfs_pointer| - create_transformed_commit(content_or_lfs_pointer) - end + result = transformer.new_file(@file_path, @file_content) + + create_transformed_commit(result.content) end private diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index a03c59f569d..13a1dee4173 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -3,11 +3,33 @@ module Files UPDATE_FILE_ACTIONS = %w(update move delete).freeze def create_commit! + transformer = Lfs::FileTransformer.new(project, @branch_name) + + actions = actions_after_lfs_transformation(transformer, params[:actions]) + + commit_actions!(actions) + end + + private + + def actions_after_lfs_transformation(transformer, actions) + actions.map do |action| + if action[:action] == 'create' + result = transformer.new_file(action[:file_path], action[:content], encoding: action[:encoding]) + action[:content] = result.content + action[:encoding] = result.encoding + end + + action + end + end + + def commit_actions!(actions) repository.multi_action( current_user, message: @commit_message, branch_name: @branch_name, - actions: params[:actions], + actions: actions, author_email: @author_email, author_name: @author_name, start_project: @start_project, @@ -17,8 +39,6 @@ module Files raise_error(e) end - private - def validate! super diff --git a/app/services/lfs/file_modification_handler.rb b/app/services/lfs/file_modification_handler.rb deleted file mode 100644 index fe9091a6e5d..00000000000 --- a/app/services/lfs/file_modification_handler.rb +++ /dev/null @@ -1,42 +0,0 @@ -module Lfs - class FileModificationHandler - attr_reader :project, :branch_name - - delegate :repository, to: :project - - def initialize(project, branch_name) - @project = project - @branch_name = branch_name - end - - def new_file(file_path, file_content) - if project.lfs_enabled? && lfs_file?(file_path) - lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content) - lfs_object = create_lfs_object!(lfs_pointer_file, file_content) - content = lfs_pointer_file.pointer - - success = yield(content) - - link_lfs_object!(lfs_object) if success - else - yield(file_content) - end - end - - private - - def lfs_file?(file_path) - repository.attributes_at(branch_name, file_path)['filter'] == 'lfs' - end - - def create_lfs_object!(lfs_pointer_file, file_content) - LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object| - lfs_object.file = CarrierWaveStringFile.new(file_content) - end - end - - def link_lfs_object!(lfs_object) - project.lfs_objects << lfs_object - end - end -end diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb new file mode 100644 index 00000000000..69281ee3137 --- /dev/null +++ b/app/services/lfs/file_transformer.rb @@ -0,0 +1,66 @@ +module Lfs + # Usage: Calling `new_file` check to see if a file should be in LFS and + # return a transformed result with `content` and `encoding` to commit. + # + # For LFS an LfsObject linked to the project is stored and an LFS + # pointer returned. If the file isn't in LFS the untransformed content + # is returned to save in the commit. + # + # transformer = Lfs::FileTransformer.new(project, @branch_name) + # content_or_lfs_pointer = transformer.new_file(file_path, content).content + # create_transformed_commit(content_or_lfs_pointer) + # + class FileTransformer + attr_reader :project, :branch_name + + delegate :repository, to: :project + + def initialize(project, branch_name) + @project = project + @branch_name = branch_name + end + + def new_file(file_path, file_content, encoding: nil) + if project.lfs_enabled? && lfs_file?(file_path) + file_content = Base64.decode64(file_content) if encoding == 'base64' + lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content) + lfs_object = create_lfs_object!(lfs_pointer_file, file_content) + + link_lfs_object!(lfs_object) + + Result.new(content: lfs_pointer_file.pointer, encoding: 'text') + else + Result.new(content: file_content, encoding: encoding) + end + end + + class Result + attr_reader :content, :encoding + + def initialize(content:, encoding:) + @content = content + @encoding = encoding + end + end + + private + + def lfs_file?(file_path) + cached_attributes.attributes(file_path)['filter'] == 'lfs' + end + + def cached_attributes + @cached_attributes ||= Gitlab::Git::AttributesAtRefParser.new(repository, branch_name) + end + + def create_lfs_object!(lfs_pointer_file, file_content) + LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object| + lfs_object.file = CarrierWaveStringFile.new(file_content) + end + end + + def link_lfs_object!(lfs_object) + project.lfs_objects << lfs_object + end + end +end diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb index 2945a7fd4e4..10aa9ae609c 100644 --- a/app/services/merge_requests/merge_request_diff_cache_service.rb +++ b/app/services/merge_requests/merge_request_diff_cache_service.rb @@ -1,8 +1,17 @@ module MergeRequests class MergeRequestDiffCacheService - def execute(merge_request) + def execute(merge_request, new_diff) # Executing the iteration we cache all the highlighted diff information merge_request.diffs.diff_files.to_a + + # Remove cache for all diffs on this MR. Do not use the association on the + # model, as that will interfere with other actions happening when + # reloading the diff. + MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| + next if merge_request_diff == new_diff + + merge_request_diff.diffs.clear_cache! + end end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e07ecda27b5..ab94db2c1e5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -208,9 +208,9 @@ class NotificationService def new_access_request(member) return true unless member.notifiable?(:subscription) - recipients = member.source.members.owners_and_masters + recipients = member.source.members.active_without_invites.owners_and_masters if fallback_to_group_owners_masters?(recipients, member) - recipients = member.source.group.members.owners_and_masters + recipients = member.source.group.members.active_without_invites.owners_and_masters end recipients.each { |recipient| deliver_access_request_email(recipient, member) } diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index af41ce82f65..d16aa3de639 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -26,7 +26,7 @@ module Projects end def project_tree_saver - Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared) + Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared, params: @params) end def uploads_saver diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 185e9d7b35d..37269862de6 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -9,6 +9,10 @@ %span.runner-state.runner-state-specific Specific +- add_to_breadcrumbs _("Runners"), admin_runners_path +- breadcrumb_title "##{@runner.id}" +- @no_container = true + - if @runner.shared? .bs-callout.bs-callout-success %h4 This Runner will process jobs from ALL UNASSIGNED projects diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index f9bfc01f213..8680ec2e298 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -2,8 +2,12 @@ - blob = discussion.blob - discussions = { discussion.original_line_code => [discussion] } - diff_file_class = diff_file.text? ? 'text-file' : 'js-image-file' +- diff_data = {} +- expanded = discussion.expanded? || local_assigns.fetch(:expanded, nil) +- unless expanded + - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) } -.diff-file.file-holder{ class: diff_file_class } +.diff-file.file-holder{ class: diff_file_class, data: diff_data } .js-file-title.file-title.file-title-flex-parent .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false @@ -11,17 +15,24 @@ - if diff_file.text? .diff-content.code.js-syntax-highlight %table - = render partial: "projects/diffs/line", - collection: discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: diff_file, - discussions: discussions, - discussion_expanded: true, - plain: true } + - if expanded + - discussions = { discussion.original_line_code => [discussion] } + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: diff_file, + discussions: discussions, + discussion_expanded: true, + plain: true } + - else + %tr.line_holder.line-holder-placeholder + %td.old_line.diff-line-num + %td.new_line.diff-line-num + %td.line_content + .js-code-placeholder + = render "discussions/diff_discussion", discussions: [discussion], expanded: true - else - partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff' - = render partial: "projects/diffs/#{partial}", locals: { diff_file: diff_file, position: discussion.position.to_json, click_to_comment: false } - .note-container = render partial: "discussions/notes", locals: { discussion: discussion, show_toggle: false, show_image_comment_badge: true, disable_collapse_class: true } diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 8b9fa3d6b05..e9589213f80 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -8,7 +8,7 @@ .discussion.js-toggle-container{ data: { discussion_id: discussion.id } } .discussion-header .discussion-actions - %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" } + %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) } - if expanded = icon("chevron-up") - else diff --git a/app/views/peek/views/_gc.html.haml b/app/views/peek/views/_gc.html.haml new file mode 100644 index 00000000000..9fc83e56ee7 --- /dev/null +++ b/app/views/peek/views/_gc.html.haml @@ -0,0 +1,7 @@ +- local_assigns.fetch(:view) + +%span.bold + %span{ title: 'Invoke Time', data: { defer_to: "#{view.defer_key}-gc_time" } }... + \/ + %span{ title: 'Invoke Count', data: { defer_to: "#{view.defer_key}-invokes" } }... +gc diff --git a/app/views/peek/views/_gitaly.html.haml b/app/views/peek/views/_gitaly.html.haml index a7d040d6821..945bb287429 100644 --- a/app/views/peek/views/_gitaly.html.haml +++ b/app/views/peek/views/_gitaly.html.haml @@ -1,7 +1,17 @@ - local_assigns.fetch(:view) -%strong - %span{ data: { defer_to: "#{view.defer_key}-duration" } } ... +%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" } } ... - Gitaly + %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/_redis.html.haml b/app/views/peek/views/_redis.html.haml new file mode 100644 index 00000000000..f7fba6c95fc --- /dev/null +++ b/app/views/peek/views/_redis.html.haml @@ -0,0 +1,7 @@ +- local_assigns.fetch(:view) + +%span.bold + %span{ data: { defer_to: "#{view.defer_key}-duration" } }... + \/ + %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +redis diff --git a/app/views/peek/views/_sidekiq.html.haml b/app/views/peek/views/_sidekiq.html.haml new file mode 100644 index 00000000000..7efbc05890d --- /dev/null +++ b/app/views/peek/views/_sidekiq.html.haml @@ -0,0 +1,7 @@ +- local_assigns.fetch(:view) + +%span.bold + %span{ data: { defer_to: "#{view.defer_key}-duration" } }... + \/ + %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +sidekiq diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml index dd8b524064f..36583df898a 100644 --- a/app/views/peek/views/_sql.html.haml +++ b/app/views/peek/views/_sql.html.haml @@ -1,13 +1,14 @@ -%strong - %a.js-toggle-modal-peek-sql - %span{ data: { defer_to: "#{view.defer_key}-duration" } }... - \/ - %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +%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.btn.btn-link.btn-sm{ type: 'button', data: { dismiss: 'modal' } } X + %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/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 053ea24b848..684b082efbb 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -15,11 +15,6 @@ .footer-block.row-content-block = service_save_button(@service) - - if @service.valid? && @service.activated? - - unless @service.can_test? - - disabled_class = 'disabled' - - disabled_title = @service.disabled_title - = link_to 'Cancel', project_settings_integrations_path(@project), class: 'btn btn-cancel' - if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true) diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index c100852374a..0b502143e5d 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -4,10 +4,11 @@ class ProjectExportWorker sidekiq_options retry: 3 - def perform(current_user_id, project_id) + def perform(current_user_id, project_id, params = {}) + params = params.with_indifferent_access current_user = User.find(current_user_id) project = Project.find(project_id) - ::Projects::ImportExport::ExportService.new(project, current_user).execute + ::Projects::ImportExport::ExportService.new(project, current_user, params).execute end end diff --git a/changelogs/unreleased/35475-lazy-diff.yml b/changelogs/unreleased/35475-lazy-diff.yml new file mode 100644 index 00000000000..bafa66ebe39 --- /dev/null +++ b/changelogs/unreleased/35475-lazy-diff.yml @@ -0,0 +1,5 @@ +--- +title: lazy load diffs on merge request discussions +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml b/changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml new file mode 100644 index 00000000000..60a649f22c9 --- /dev/null +++ b/changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml @@ -0,0 +1,5 @@ +--- +title: Adds the option to the project export API to override the project description and display GitLab export description once imported +merge_request: 17744 +author: +type: added diff --git a/changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml b/changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml new file mode 100644 index 00000000000..3aec71d5ac4 --- /dev/null +++ b/changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml @@ -0,0 +1,5 @@ +--- +title: Set breadcrumb for admin/runners/show +merge_request: 17431 +author: Takuya Noguchi +type: fixed diff --git a/changelogs/unreleased/43720-update-fe-webpack-docs.yml b/changelogs/unreleased/43720-update-fe-webpack-docs.yml new file mode 100644 index 00000000000..9e461eaaec8 --- /dev/null +++ b/changelogs/unreleased/43720-update-fe-webpack-docs.yml @@ -0,0 +1,6 @@ +--- +title: Update documentation to reflect current minimum required versions of node and + yarn +merge_request: 17706 +author: +type: other diff --git a/changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml b/changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml new file mode 100644 index 00000000000..4c63e69f0bb --- /dev/null +++ b/changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml @@ -0,0 +1,5 @@ +--- +title: Add Gitaly call details to performance bar +merge_request: +author: +type: added diff --git a/changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml b/changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml new file mode 100644 index 00000000000..7335d313510 --- /dev/null +++ b/changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml @@ -0,0 +1,5 @@ +--- +title: Update ruby-saml to 1.7.2 and omniauth-saml to 1.10.0 +merge_request: 17734 +author: Takuya Noguchi +type: security diff --git a/changelogs/unreleased/44024-fix-table-extra-column.yml b/changelogs/unreleased/44024-fix-table-extra-column.yml new file mode 100644 index 00000000000..92c354a0844 --- /dev/null +++ b/changelogs/unreleased/44024-fix-table-extra-column.yml @@ -0,0 +1,5 @@ +--- +title: Fix markdown table showing extra column +merge_request: 17669 +author: +type: fixed diff --git a/changelogs/unreleased/44149-issue-comment-buttons.yml b/changelogs/unreleased/44149-issue-comment-buttons.yml new file mode 100644 index 00000000000..c874c0d3d66 --- /dev/null +++ b/changelogs/unreleased/44149-issue-comment-buttons.yml @@ -0,0 +1,5 @@ +--- +title: Fix broken loading state for close issue button +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/44160-update-foreman-to-0-84-0.yml b/changelogs/unreleased/44160-update-foreman-to-0-84-0.yml new file mode 100644 index 00000000000..990d188eb78 --- /dev/null +++ b/changelogs/unreleased/44160-update-foreman-to-0-84-0.yml @@ -0,0 +1,5 @@ +--- +title: Update foreman from 0.78.0 to 0.84.0 +merge_request: 17690 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml b/changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml new file mode 100644 index 00000000000..8fdca6eec83 --- /dev/null +++ b/changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml @@ -0,0 +1,5 @@ +--- +title: Stop caching highlighted diffs in Redis unnecessarily +merge_request: 17746 +author: +type: performance diff --git a/changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml b/changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml new file mode 100644 index 00000000000..265d36b763f --- /dev/null +++ b/changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml @@ -0,0 +1,5 @@ +--- +title: Update knapsack to 1.16.0 +merge_request: 17735 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml b/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml new file mode 100644 index 00000000000..3b057373e7d --- /dev/null +++ b/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml @@ -0,0 +1,5 @@ +--- +title: Add slash command for moving issues +merge_request: +author: Adam Pahlevi +type: added diff --git a/changelogs/unreleased/bvl-import-zip-multiple-assignees.yml b/changelogs/unreleased/bvl-import-zip-multiple-assignees.yml new file mode 100644 index 00000000000..86bd5faf8ed --- /dev/null +++ b/changelogs/unreleased/bvl-import-zip-multiple-assignees.yml @@ -0,0 +1,5 @@ +--- +title: Fix importing multiple assignees from GitLab export +merge_request: 17718 +author: +type: fixed diff --git a/changelogs/unreleased/fix-42459---in-branch.yml b/changelogs/unreleased/fix-42459---in-branch.yml new file mode 100644 index 00000000000..26cc2046206 --- /dev/null +++ b/changelogs/unreleased/fix-42459---in-branch.yml @@ -0,0 +1,5 @@ +--- +title: Fix relative uri when "#" is in branch name +merge_request: +author: Jan +type: fixed diff --git a/changelogs/unreleased/fix-code-search-500-with-non-ascii-filename.yml b/changelogs/unreleased/fix-code-search-500-with-non-ascii-filename.yml deleted file mode 100644 index 29e3b7be985..00000000000 --- a/changelogs/unreleased/fix-code-search-500-with-non-ascii-filename.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix code and wiki search results when filename is non-ASCII -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix-dropzone-project-show.yml b/changelogs/unreleased/fix-dropzone-project-show.yml new file mode 100644 index 00000000000..660780812d8 --- /dev/null +++ b/changelogs/unreleased/fix-dropzone-project-show.yml @@ -0,0 +1,5 @@ +--- +title: Fix file upload on project show page +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fix-emoji-popup.yml b/changelogs/unreleased/fix-emoji-popup.yml new file mode 100644 index 00000000000..c81d061a5d7 --- /dev/null +++ b/changelogs/unreleased/fix-emoji-popup.yml @@ -0,0 +1,5 @@ +--- +title: Hide emoji popup after multiple spaces +merge_request: +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/jej-commit-api-tracks-lfs.yml b/changelogs/unreleased/jej-commit-api-tracks-lfs.yml new file mode 100644 index 00000000000..8284abf9f28 --- /dev/null +++ b/changelogs/unreleased/jej-commit-api-tracks-lfs.yml @@ -0,0 +1,5 @@ +--- +title: Create commit API and Web IDE obey LFS filters +merge_request: 16718 +author: +type: fixed diff --git a/changelogs/unreleased/jivl-realtime-update-adding-file.yml b/changelogs/unreleased/jivl-realtime-update-adding-file.yml new file mode 100644 index 00000000000..df1bdb1648d --- /dev/null +++ b/changelogs/unreleased/jivl-realtime-update-adding-file.yml @@ -0,0 +1,5 @@ +--- +title: Add realtime pipeline status for adding/viewing files +merge_request: 17705 +author: +type: other diff --git a/changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml b/changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml new file mode 100644 index 00000000000..a31f1f372a8 --- /dev/null +++ b/changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move UnresolvedDiscussions vue component +merge_request: 17538 +author: George Tsiolis +type: performance diff --git a/changelogs/unreleased/sh-add-missing-acts-as-taggable-indices.yml b/changelogs/unreleased/sh-add-missing-acts-as-taggable-indices.yml deleted file mode 100644 index d9a1a0db9e8..00000000000 --- a/changelogs/unreleased/sh-add-missing-acts-as-taggable-indices.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Adding missing indexes on taggings table -merge_request: -author: -type: performance diff --git a/changelogs/unreleased/sh-add-section-name-index.yml b/changelogs/unreleased/sh-add-section-name-index.yml deleted file mode 100644 index c822b4e851b..00000000000 --- a/changelogs/unreleased/sh-add-section-name-index.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Add index on section_name_id on ci_build_trace_sections table -merge_request: -author: -type: performance diff --git a/changelogs/unreleased/sh-cache-column-exists.yml b/changelogs/unreleased/sh-cache-column-exists.yml new file mode 100644 index 00000000000..8bc648f2b32 --- /dev/null +++ b/changelogs/unreleased/sh-cache-column-exists.yml @@ -0,0 +1,5 @@ +--- +title: Cache column_exists? for application settings +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/sh-cache-table-exists.yml b/changelogs/unreleased/sh-cache-table-exists.yml new file mode 100644 index 00000000000..37407b2a005 --- /dev/null +++ b/changelogs/unreleased/sh-cache-table-exists.yml @@ -0,0 +1,5 @@ +--- +title: Cache table_exists?('application_settings') to reduce repeated schema reloads +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/sh-optimize-admin-projects-page.yml b/changelogs/unreleased/sh-optimize-admin-projects-page.yml new file mode 100644 index 00000000000..242ea758dab --- /dev/null +++ b/changelogs/unreleased/sh-optimize-admin-projects-page.yml @@ -0,0 +1,5 @@ +--- +title: Fix timeouts loading /admin/projects page +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/sh-remove-double-caching-repo-empty.yml b/changelogs/unreleased/sh-remove-double-caching-repo-empty.yml deleted file mode 100644 index 1684be4e5e3..00000000000 --- a/changelogs/unreleased/sh-remove-double-caching-repo-empty.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Remove double caching of Repository#empty? -merge_request: -author: -type: fixed 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 diff --git a/config/application.rb b/config/application.rb index 422b16a7719..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 (:value) config.filter_parameters += [/token$/, /password/, /secret/] config.filter_parameters += %i( certificate @@ -82,7 +81,6 @@ module Gitlab sentry_dsn trace variables - value ) # Enable escaping HTML in JSON. diff --git a/config/initializers/peek.rb b/config/initializers/peek.rb index 11759801112..ba04a2bf5fa 100644 --- a/config/initializers/peek.rb +++ b/config/initializers/peek.rb @@ -16,11 +16,11 @@ else end Peek.into PEEK_DB_VIEW +Peek.into Peek::Views::Gitaly +Peek.into Peek::Views::Rblineprof Peek.into Peek::Views::Redis Peek.into Peek::Views::Sidekiq -Peek.into Peek::Views::Rblineprof Peek.into Peek::Views::GC -Peek.into Peek::Views::Gitaly # rubocop:disable Naming/ClassAndModuleCamelCase class PEEK_DB_CLIENT diff --git a/config/routes/project.rb b/config/routes/project.rb index b82ed27664c..c803737d40b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -130,7 +130,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do post :bulk_update end - resources :discussions, only: [], constraints: { id: /\h{40}/ } do + resources :discussions, only: [:show], constraints: { id: /\h{40}/ } do member do post :resolve delete :resolve, action: :unresolve diff --git a/db/fixtures/development/22_labeled_issues_seed.rb b/db/fixtures/development/22_labeled_issues_seed.rb new file mode 100644 index 00000000000..3730e9c7958 --- /dev/null +++ b/db/fixtures/development/22_labeled_issues_seed.rb @@ -0,0 +1,103 @@ +# Creates a project with labeled issues for an user. +# Run this single seed file using: rake db:seed_fu FILTER=labeled USER_ID=74. +# If an USER_ID is not provided it will use the last created user. +require './spec/support/sidekiq' + +class Gitlab::Seeder::LabeledIssues + include ::Gitlab::Utils + + def initialize(user) + @user = user + end + + def seed! + Sidekiq::Testing.inline! do + group = create_group + + create_projects(group) + create_labels(group) + create_issues(group) + end + + print '.' + end + + private + + def create_group + group_name = "group_of_#{@user.username}_#{SecureRandom.hex(4)}" + + group_params = { + name: group_name, + path: group_name, + description: FFaker::Lorem.sentence + } + + Groups::CreateService.new(@user, group_params).execute + end + + def create_projects(group) + 5.times do + project_name = "project_#{SecureRandom.hex(6)}" + + params = { + namespace_id: group.id, + name: project_name, + description: FFaker::Lorem.sentence, + visibility_level: Gitlab::VisibilityLevel.values.sample + } + + Projects::CreateService.new(@user, params).execute + end + end + + def create_labels(group) + group.projects.each do |project| + 5.times do + label_title = FFaker::Vehicle.model + Labels::CreateService.new(title: label_title, color: "#69D100").execute(project: project) + end + end + + 10.times do + label_title = FFaker::Product.brand + Labels::CreateService.new(title: label_title).execute(group: group) + end + end + + def create_issues(group) + # Get only group labels + group_labels = + LabelsFinder.new(@user, group_id: group.id).execute.where.not(group_id: nil) + + group.projects.each do |project| + label_ids = project.labels.pluck(:id).sample(5) + label_ids.push(*group.labels.sample(4)) + + 20.times do + issue_params = { + title: FFaker::Lorem.sentence(6), + description: FFaker::Lorem.sentence, + state: 'opened', + label_ids: label_ids + + } + + Issues::CreateService.new(project, @user, issue_params).execute if project.project_feature.present? + end + end + end +end + +Gitlab::Seeder.quiet do + user_id = ENV['USER_ID'] + + user = + if user_id.present? + User.find(user_id) + else + User.last + end + + Gitlab::Seeder::LabeledIssues.new(user).seed! +end diff --git a/db/migrate/20180223120443_create_user_interacted_projects_table.rb b/db/migrate/20180223120443_create_user_interacted_projects_table.rb index 20749940b1e..8da8cf68088 100644 --- a/db/migrate/20180223120443_create_user_interacted_projects_table.rb +++ b/db/migrate/20180223120443_create_user_interacted_projects_table.rb @@ -3,13 +3,15 @@ class CreateUserInteractedProjectsTable < ActiveRecord::Migration DOWNTIME = false - disable_ddl_transaction! + INDEX_NAME = 'user_interacted_projects_non_unique_index' def up create_table :user_interacted_projects, id: false do |t| t.references :user, null: false t.references :project, null: false end + + add_index :user_interacted_projects, [:project_id, :user_id], name: INDEX_NAME end def down 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 5e729b1aa53..d1a29a5c71b 100644 --- a/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb +++ b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb @@ -1,9 +1,14 @@ +require_relative '../migrate/20180223120443_create_user_interacted_projects_table.rb' +# rubocop:disable AddIndex +# rubocop:disable AddConcurrentForeignKey class BuildUserInteractedProjectsTable < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers # Set this constant to true if this migration requires downtime. DOWNTIME = false + UNIQUE_INDEX_NAME = 'index_user_interacted_projects_on_project_id_and_user_id' + disable_ddl_transaction! def up @@ -13,16 +18,8 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration MysqlStrategy.new end.up - unless index_exists?(:user_interacted_projects, [:project_id, :user_id]) - add_concurrent_index :user_interacted_projects, [:project_id, :user_id], unique: true - end - - unless foreign_key_exists?(:user_interacted_projects, :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) - add_concurrent_foreign_key :user_interacted_projects, :projects, column: :project_id, on_delete: :cascade + if index_exists_by_name?(:user_interacted_projects, CreateUserInteractedProjectsTable::INDEX_NAME) + remove_concurrent_index_by_name :user_interacted_projects, CreateUserInteractedProjectsTable::INDEX_NAME end end @@ -37,31 +34,16 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration remove_foreign_key :user_interacted_projects, :projects end - if index_exists_by_name?(:user_interacted_projects, 'index_user_interacted_projects_on_project_id_and_user_id') - remove_concurrent_index_by_name :user_interacted_projects, 'index_user_interacted_projects_on_project_id_and_user_id' + if index_exists_by_name?(:user_interacted_projects, UNIQUE_INDEX_NAME) + remove_concurrent_index_by_name :user_interacted_projects, UNIQUE_INDEX_NAME end - end - private - - # 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. - def index_exists_by_name?(table, index) - indexes_for_table[table].include?(index) - end - - def indexes_for_table - @indexes_for_table ||= Hash.new do |hash, table_name| - hash[table_name] = indexes(table_name).map(&:name) + unless index_exists_by_name?(:user_interacted_projects, CreateUserInteractedProjectsTable::INDEX_NAME) + add_concurrent_index :user_interacted_projects, [:project_id, :user_id], name: CreateUserInteractedProjectsTable::INDEX_NAME end end - def foreign_key_exists?(table, column) - foreign_keys(table).any? do |key| - key.options[:column] == column.to_s - end - end + private class PostgresStrategy < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers @@ -71,33 +53,86 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration def up with_index(:events, [:author_id, :project_id], name: 'events_user_interactions_temp', where: 'project_id IS NOT NULL') do - iteration = 0 - records = 0 - begin - Rails.logger.info "Building user_interacted_projects table, batch ##{iteration}" - result = execute <<~SQL + insert_missing_records + + # Do this once without lock to speed up the second invocation + remove_duplicates + with_table_lock(:user_interacted_projects) do + remove_duplicates + create_unique_index + end + + remove_without_project + with_table_lock(:user_interacted_projects, :projects) do + remove_without_project + create_fk :user_interacted_projects, :projects, :project_id + end + + remove_without_user + with_table_lock(:user_interacted_projects, :users) do + remove_without_user + create_fk :user_interacted_projects, :users, :user_id + end + end + + execute "ANALYZE user_interacted_projects" + end + + private + def insert_missing_records + iteration = 0 + records = 0 + begin + Rails.logger.info "Building user_interacted_projects table, batch ##{iteration}" + result = execute <<~SQL INSERT INTO user_interacted_projects (user_id, project_id) SELECT e.user_id, e.project_id FROM (SELECT DISTINCT author_id AS user_id, project_id FROM events WHERE project_id IS NOT NULL) AS e LEFT JOIN user_interacted_projects ucp USING (user_id, project_id) WHERE ucp.user_id IS NULL LIMIT #{BATCH_SIZE} - SQL - iteration += 1 - records += result.cmd_tuples - Rails.logger.info "Building user_interacted_projects table, batch ##{iteration} complete, created #{records} overall" - Kernel.sleep(SLEEP_TIME) if result.cmd_tuples > 0 - rescue ActiveRecord::InvalidForeignKey => e - Rails.logger.info "Retry on InvalidForeignKey: #{e}" - retry - end while result.cmd_tuples > 0 - end + SQL + iteration += 1 + records += result.cmd_tuples + Rails.logger.info "Building user_interacted_projects table, batch ##{iteration} complete, created #{records} overall" + Kernel.sleep(SLEEP_TIME) if result.cmd_tuples > 0 + end while result.cmd_tuples > 0 + end - execute "ANALYZE user_interacted_projects" + def remove_duplicates + execute <<~SQL + WITH numbered AS (select ctid, ROW_NUMBER() OVER (PARTITION BY (user_id, project_id)) as row_number, user_id, project_id from user_interacted_projects) + DELETE FROM user_interacted_projects WHERE ctid IN (SELECT ctid FROM numbered WHERE row_number > 1); + SQL + end + def remove_without_project + execute "DELETE FROM user_interacted_projects WHERE NOT EXISTS (SELECT 1 FROM projects WHERE id = user_interacted_projects.project_id)" end - private + def remove_without_user + execute "DELETE FROM user_interacted_projects WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = user_interacted_projects.user_id)" + end + + def create_fk(table, target, column) + return if foreign_key_exists?(table, column) + + add_foreign_key table, target, column: column, on_delete: :cascade + end + + def create_unique_index + return if index_exists_by_name?(:user_interacted_projects, UNIQUE_INDEX_NAME) + + add_index :user_interacted_projects, [:project_id, :user_id], unique: true, name: UNIQUE_INDEX_NAME + end + + # Protect table against concurrent data changes while still allowing reads + def with_table_lock(*tables) + ActiveRecord::Base.connection.transaction do + execute "LOCK TABLE #{tables.join(", ")} IN SHARE MODE" + yield + end + end def with_index(*args) add_concurrent_index(*args) unless index_exists?(*args) @@ -118,7 +153,18 @@ class BuildUserInteractedProjectsTable < ActiveRecord::Migration LEFT JOIN user_interacted_projects ucp USING (user_id, project_id) WHERE ucp.user_id IS NULL SQL + + unless index_exists?(:user_interacted_projects, [:project_id, :user_id]) + 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) + add_concurrent_foreign_key :user_interacted_projects, :users, column: :user_id, on_delete: :cascade + end + + unless foreign_key_exists?(:user_interacted_projects, :project_id) + add_concurrent_foreign_key :user_interacted_projects, :projects, column: :project_id, on_delete: :cascade + end end end - end diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index 6c5a466ced5..27a3710632d 100644 --- a/doc/administration/incoming_email.md +++ b/doc/administration/incoming_email.md @@ -187,6 +187,7 @@ for a real-world example of this exploit. ```sh sudo gitlab-ctl reconfigure + sudo gitlab-ctl restart ``` 1. Verify that everything is configured correctly: diff --git a/doc/administration/monitoring/performance/img/performance_bar.png b/doc/administration/monitoring/performance/img/performance_bar.png Binary files differindex b3c6bc474e3..48212f6276a 100644 --- a/doc/administration/monitoring/performance/img/performance_bar.png +++ b/doc/administration/monitoring/performance/img/performance_bar.png diff --git a/doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.png b/doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.png Binary files differnew file mode 100644 index 00000000000..52176df9ecd --- /dev/null +++ b/doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.png diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md index b9464945cea..ec1cbce1bad 100644 --- a/doc/administration/monitoring/performance/performance_bar.md +++ b/doc/administration/monitoring/performance/performance_bar.md @@ -11,10 +11,12 @@ It allows you to see (from left to right): - the timing of the page (backend, frontend) - time taken and number of DB queries, click through for details of these queries ![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png) -- time taken and number of calls to Redis -- time taken and number of background jobs created by Sidekiq +- 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)). ![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 ## Enable the Performance Bar via the Admin panel @@ -39,3 +41,5 @@ You can toggle the Bar using the same shortcut. ![GitLab Performance Bar Admin Settings](img/performance_bar_configuration_settings.png) --- + +[Gitaly]: ../../gitaly/index.md diff --git a/doc/administration/raketasks/check.md b/doc/administration/raketasks/check.md index 51c62742d01..7d34d35e7d1 100644 --- a/doc/administration/raketasks/check.md +++ b/doc/administration/raketasks/check.md @@ -84,9 +84,9 @@ checks using those checksums can be run. These checks also detect missing files. Currently, integrity checks are supported for the following types of file: -* CI artifacts -* LFS objects -* User uploads +* CI artifacts (Available from version 10.7.0) +* LFS objects (Available from version 10.6.0) +* User uploads (Available from version 10.6.0) **Omnibus Installation** diff --git a/doc/api/project_import_export.md b/doc/api/project_import_export.md index 677765368a8..de5207fc5e4 100644 --- a/doc/api/project_import_export.md +++ b/doc/api/project_import_export.md @@ -15,9 +15,10 @@ POST /projects/:id/export | Attribute | Type | Required | Description | | --------- | -------------- | -------- | ---------------------------------------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `description` | string | no | Overrides the project description | ```console -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/export +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "description=Foo Bar" https://gitlab.example.com/api/v4/projects/1/export ``` ```json diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index fb5bfe26bb0..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,6 +83,67 @@ 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 the health check of services works + +Services are designed to provide additional functionality which is **network accessible**. +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, the Runner: + +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 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 the job +will still succeed even if that warning was printed. For example: + +- 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 simplest example of such a service. + +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, 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: + services: + - php:7 + - node:latest + - golang:1.10 + image: alpine:3.7 + script: + - php -v + - node -v + - go version +``` + +If you need to have `php`, `node` and `go` available for your script, you should +either: + +- 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 Let's say that you need a Wordpress instance to test some API integration with diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index f69729f602d..c1e258aedca 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -47,8 +47,11 @@ There's also a collection of repositories with [example projects](https://gitlab ## Static Application Security Testing (SAST) -- **(Ultimate)** [Scan your code for vulnerabilities](https://docs.gitlab.com/ee/ci/examples/sast.html) -- [Scan your Docker images for vulnerabilities](sast_docker.md) +**(Ultimate)** [Scan your code for vulnerabilities](https://docs.gitlab.com/ee/ci/examples/sast.html) + +## Container Scanning + +[Scan your Docker images for vulnerabilities](container_scanning.md) ## Dynamic Application Security Testing (DAST) diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md index d7df53494ed..ec5e5afb8c6 100644 --- a/doc/ci/examples/code_climate.md +++ b/doc/ci/examples/code_climate.md @@ -16,18 +16,26 @@ codequality: - docker:dind script: - docker pull codeclimate/codeclimate - - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate:0.69.0 init - - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate:0.69.0 analyze -f json > codeclimate.json || true + - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') + - docker run + --env SOURCE_CODE="$PWD" \ + --volume "$PWD":/code \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code artifacts: paths: [codeclimate.json] ``` -This will create a `codequality` job in your CI pipeline and will allow you to -download and analyze the report artifact in JSON format. +The above example will create a `codequality` job in your CI/CD pipeline which +will scan your source code for code quality issues. The report will be saved +as an artifact that you can later download and analyze. -For [GitLab Starter][ee] users, this information can be automatically -extracted and shown right in the merge request widget. [Learn more on code quality -diffs in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html). +TIP: **Tip:** +Starting with [GitLab Starter][ee] 9.3, this information will +be automatically extracted and shown right in the merge request widget. To do +so, the CI/CD job must be named `codequality` and the artifact path must be +`codeclimate.json`. +[Learn more on code quality diffs in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html). [cli]: https://github.com/codeclimate/codeclimate [dind]: ../docker/using_docker_build.md#use-docker-in-docker-executor diff --git a/doc/ci/examples/container_scanning.md b/doc/ci/examples/container_scanning.md new file mode 100644 index 00000000000..3437b63748a --- /dev/null +++ b/doc/ci/examples/container_scanning.md @@ -0,0 +1,55 @@ +# Container Scanning with GitLab CI/CD + +You can check your Docker images (or more precisely the containers) for known +vulnerabilities by using [Clair](https://github.com/coreos/clair) and +[clair-scanner](https://github.com/arminc/clair-scanner), two open source tools +for Vulnerability Static Analysis for containers. + +All you need is a GitLab Runner with the Docker executor (the shared Runners on +GitLab.com will work fine). You can then add a new job to `.gitlab-ci.yml`, +called `sast:container`: + +```yaml +sast:container: + image: docker:latest + variables: + DOCKER_DRIVER: overlay2 + ## Define two new variables based on GitLab's CI/CD predefined variables + ## https://docs.gitlab.com/ee/ci/variables/#predefined-variables-environment-variables + CI_APPLICATION_REPOSITORY: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG + CI_APPLICATION_TAG: $CI_COMMIT_SHA + allow_failure: true + services: + - docker:dind + script: + - docker run -d --name db arminc/clair-db:latest + - docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.0.1 + - apk add -U wget ca-certificates + - docker pull ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG} + - wget https://github.com/arminc/clair-scanner/releases/download/v8/clair-scanner_linux_amd64 + - mv clair-scanner_linux_amd64 clair-scanner + - chmod +x clair-scanner + - touch clair-whitelist.yml + - ./clair-scanner -c http://docker:6060 --ip $(hostname -i) -r gl-sast-container-report.json -l clair.log -w clair-whitelist.yml ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG} || true + artifacts: + paths: [gl-sast-container-report.json] +``` + +The above example will create a `sast:container` job in your CI/CD pipeline, pull +the image from the [Container Registry](../../user/project/container_registry.md) +(whose name is defined from the two `CI_APPLICATION_` variables) and scan it +for possible vulnerabilities. The report will be saved as an artifact that you +can later download and analyze. + +If you want to whitelist some specific vulnerabilities, you can do so by defining +them in a [YAML file](https://github.com/arminc/clair-scanner/blob/master/README.md#example-whitelist-yaml-file), +in our case its named `clair-whitelist.yml`. + +TIP: **Tip:** +Starting with [GitLab Ultimate][ee] 10.4, this information will +be automatically extracted and shown right in the merge request widget. To do +so, the CI/CD job must be named `sast:container` and the artifact path must be +`gl-sast-container-report.json`. +[Learn more on container scanning results shown in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/container_scanning.html). + +[ee]: https://about.gitlab.com/products/ diff --git a/doc/ci/examples/sast_docker.md b/doc/ci/examples/sast_docker.md index 57a9c4bcfc1..9f4a63e296d 100644 --- a/doc/ci/examples/sast_docker.md +++ b/doc/ci/examples/sast_docker.md @@ -1,55 +1 @@ -# Static Application Security Testing for Docker containers with GitLab CI/CD - -You can check your Docker images (or more precisely the containers) for known -vulnerabilities by using [Clair](https://github.com/coreos/clair) and -[clair-scanner](https://github.com/arminc/clair-scanner), two open source tools -for Vulnerability Static Analysis for containers. - -All you need is a GitLab Runner with the Docker executor (the shared Runners on -GitLab.com will work fine). You can then add a new job to `.gitlab-ci.yml`, -called `sast:container`: - -```yaml -sast:container: - image: docker:latest - variables: - DOCKER_DRIVER: overlay2 - ## Define two new variables based on GitLab's CI/CD predefined variables - ## https://docs.gitlab.com/ee/ci/variables/#predefined-variables-environment-variables - CI_APPLICATION_REPOSITORY: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG - CI_APPLICATION_TAG: $CI_COMMIT_SHA - allow_failure: true - services: - - docker:dind - script: - - docker run -d --name db arminc/clair-db:latest - - docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.0.1 - - apk add -U wget ca-certificates - - docker pull ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG} - - wget https://github.com/arminc/clair-scanner/releases/download/v8/clair-scanner_linux_amd64 - - mv clair-scanner_linux_amd64 clair-scanner - - chmod +x clair-scanner - - touch clair-whitelist.yml - - ./clair-scanner -c http://docker:6060 --ip $(hostname -i) -r gl-sast-container-report.json -l clair.log -w clair-whitelist.yml ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG} || true - artifacts: - paths: [gl-sast-container-report.json] -``` - -The above example will create a `sast:container` job in your CI/CD pipeline, pull -the image from the [Container Registry](../../user/project/container_registry.md) -(whose name is defined from the two `CI_APPLICATION_` variables) and scan it -for possible vulnerabilities. The report will be saved as an artifact that you -can later download and analyze. - -If you want to whitelist some specific vulnerabilities, you can do so by defining -them in a [YAML file](https://github.com/arminc/clair-scanner/blob/master/README.md#example-whitelist-yaml-file), -in our case its named `clair-whitelist.yml`. - -TIP: **Tip:** -Starting with [GitLab Ultimate][ee] 10.4, this information will -be automatically extracted and shown right in the merge request widget. To do -so, the CI/CD job must be named `sast:container` and the artifact path must be -`gl-sast-container-report.json`. -[Learn more on application security testing results shown in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/sast_docker.html). - -[ee]: https://about.gitlab.com/products/ +This document was moved to [another location](./container_scanning.md).
\ No newline at end of file diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ae200f9b6e2..accf6340398 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -966,7 +966,7 @@ tag including only the files that are untracked by Git: ```yaml job: artifacts: - name: "${CI_JOB_NAME}_${CI_COMMIT_REF_NAME}" + name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME" untracked: true ``` @@ -975,7 +975,7 @@ To create an archive with a name of the current [stage](#stages) and branch name ```yaml job: artifacts: - name: "${CI_JOB_STAGE}_${CI_COMMIT_REF_NAME}" + name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME" untracked: true ``` @@ -987,7 +987,7 @@ If you use **Windows Batch** to run your shell scripts you need to replace ```yaml job: artifacts: - name: "%CI_JOB_STAGE%_%CI_COMMIT_REF_NAME%" + name: "%CI_JOB_STAGE%-%CI_COMMIT_REF_NAME%" untracked: true ``` @@ -997,7 +997,7 @@ If you use **Windows PowerShell** to run your shell scripts you need to replace ```yaml job: artifacts: - name: "$env:CI_JOB_STAGE_$env:CI_COMMIT_REF_NAME" + name: "$env:CI_JOB_STAGE-$env:CI_COMMIT_REF_NAME" untracked: true ``` diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 6fe5f647d6c..41e3412c7ff 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -1,89 +1,28 @@ -# Documentation styleguide +# Documentation style guidelines -This styleguide recommends best practices to improve documentation and to keep -it organized and easy to find. +The documentation style guide defines the markup structure used in +GitLab documentation. Check the +[documentation guidelines](writing_documentation.md) for general development instructions. -See also [writing documentation](writing_documentation.md). - -## Location and naming of documents - ->**Note:** -These guidelines derive from the discussion taken place in issue [#3349][ce-3349]. - -The documentation hierarchy can be vastly improved by providing a better layout -and organization of directories. - -Having a structured document layout, we will be able to have meaningful URLs -like `docs.gitlab.com/user/project/merge_requests.html`. With this pattern, -you can immediately tell that you are navigating a user related documentation -and is about the project and its merge requests. - -Do not create summaries of similar types of content (e.g. an index of all articles, videos, etc.), -rather organise content by its subject (e.g. everything related to CI goes together) -and cross-link between any related content. - -The table below shows what kind of documentation goes where. - -| Directory | What belongs here | -| --------- | -------------- | -| `doc/user/` | User related documentation. Anything that can be done within the GitLab UI goes here including `/admin`. | -| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. The admin settings that can be accessed via GitLab's interface go under `doc/user/admin_area/`. | -| `doc/api/` | API related documentation. | -| `doc/development/` | Documentation related to the development of GitLab. Any styleguides should go here. | -| `doc/legal/` | Legal documents about contributing to GitLab. | -| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). | -| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. | -| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`): all resources for that topic (user and admin documentation, articles, and third-party docs) | - ---- - -**General rules:** - -1. The correct naming and location of a new document, is a combination - of the relative URL of the document in question and the GitLab Map design - that is used for UX purposes ([source][graffle], [image][gitlab-map]). -1. When creating a new document and it has more than one word in its name, - make sure to use underscores instead of spaces or dashes (`-`). For example, - a proper naming would be `import_projects_from_github.md`. The same rule - applies to images. -1. There are four main directories, `user`, `administration`, `api` and `development`. -1. The `doc/user/` directory has five main subdirectories: `project/`, `group/`, - `profile/`, `dashboard/` and `admin_area/`. - 1. `doc/user/project/` should contain all project related documentation. - 1. `doc/user/group/` should contain all group related documentation. - 1. `doc/user/profile/` should contain all profile related documentation. - Every page you would navigate under `/profile` should have its own document, - i.e. `account.md`, `applications.md`, `emails.md`, etc. - 1. `doc/user/dashboard/` should contain all dashboard related documentation. - 1. `doc/user/admin_area/` should contain all admin related documentation - describing what can be achieved by accessing GitLab's admin interface - (_not to be confused with `doc/administration` where server access is - required_). - 1. Every category under `/admin/application_settings` should have its - own document located at `doc/user/admin_area/settings/`. For example, - the **Visibility and Access Controls** category should have a document - located at `doc/user/admin_area/settings/visibility_and_access_controls.md`. -1. The `doc/topics/` directory holds topic-related technical content. Create - `doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary. - General user- and admin- related documentation, should be placed accordingly. - ---- - -If you are unsure where a document should live, you can ping `@axil` or `@marcia` in your -merge request. +Check the GitLab hanbook for the [writing styles guidelines](https://about.gitlab.com/handbook/communication/#writing-style-guidelines). ## Text -- Split up long lines, this makes it much easier to review and edit. Only +- Split up long lines (wrap text), this makes it much easier to review and edit. Only double line breaks are shown as a full line break in [GitLab markdown][gfm]. 80-100 characters is a good line length -- Make sure that the documentation is added in the correct directory and that +- Make sure that the documentation is added in the correct + [directory](writing_documentation.md#documentation-directory-structure) and that there's a link to it somewhere useful - Do not duplicate information - Be brief and clear - Unless there's a logical reason not to, add documents in alphabetical order - Write in US English - Use [single spaces][] instead of double spaces +- Jump a line between different markups (e.g., after every paragraph, hearder, list, etc) +- Capitalize "G" and "L" in GitLab +- Capitalize feature, products, and methods names. E.g.: GitLab Runner, Geo, +Issue Boards, Git, Prometheus, Continuous Integration. ## Formatting @@ -103,6 +42,8 @@ merge request. links shift too, which eventually leads to dead links. If you think it is compelling to add numbers in headings, make sure to at least discuss it with someone in the Merge Request +- [Avoid using symbols and special chars](https://gitlab.com/gitlab-com/gitlab-docs/issues/84) + in headers. Whenever possible, they should be plain and short text. - Avoid adding things that show ephemeral statuses. For example, if a feature is considered beta or experimental, put this info in a note, not in the heading. - When introducing a new document, be careful for the headings to be @@ -121,71 +62,18 @@ merge request. you can use `[Text][identifier]` and at the bottom of the section or the document add: `[identifier]: https://example.com`, in which case, we do encourage you to also add an alternative text: `[identifier]: https://example.com "Alternative text"` that appears when hovering your mouse on a link. - -### Linking to inline docs - -Sometimes it's needed to link to the built-in documentation that GitLab provides -under `/help`. This is normally done in files inside the `app/views/` directory -with the help of the `help_page_path` helper method. - -In its simplest form, the HAML code to generate a link to the `/help` page is: - -```haml -= link_to 'Help page', help_page_path('user/permissions') -``` - -The `help_page_path` contains the path to the document you want to link to with -the following conventions: - -- it is relative to the `doc/` directory in the GitLab repository -- the `.md` extension must be omitted -- it must not end with a slash (`/`) - -Below are some special cases where should be used depending on the context. -You can combine one or more of the following: - -1. **Linking to an anchor link.** Use `anchor` as part of the `help_page_path` - method: - - ```haml - = link_to 'Help page', help_page_path('user/permissions', anchor: 'anchor-link') - ``` - -1. **Opening links in a new tab.** This should be the default behavior: - - ```haml - = link_to 'Help page', help_page_path('user/permissions'), target: '_blank' - ``` - -1. **Linking to a circle icon.** Usually used in settings where a long - description cannot be used, like near checkboxes. You can basically use - any font awesome icon, but prefer the `question-circle`: - - ```haml - = link_to icon('question-circle'), help_page_path('user/permissions') - ``` - -1. **Using a button link.** Useful in places where text would be out of context - with the rest of the page layout: - - ```haml - = link_to 'Help page', help_page_path('user/permissions'), class: 'btn btn-info' - ``` - -1. **Using links inline of some text.** - - ```haml - Description to #{link_to 'Help page', help_page_path('user/permissions')}. - ``` - -1. **Adding a period at the end of the sentence.** Useful when you don't want - the period to be part of the link: - - ```haml - = succeed '.' do - Learn more in the - = link_to 'Help page', help_page_path('user/permissions') - ``` +- To link to internal documentation, use relative links, not full URLs. Use `../` to + navigate tp high-level directories, and always add the file name `file.md` at the + end of the link with the `.md` extension, not `.html`. + Example: instead of `[text](../../merge_requests/)`, use + `[text](../../merge_requests/index.md)` or, `[text](../../ci/README.md)`, or, + for anchor links, `[text](../../ci/README.md#examples)`. + Using the markdown extension is necessary for the [`/help`](writing_documentation.md#gitlab-help) + section of GitLab. +- To link from CE to EE-only documentation, use the EE-only doc full URL. +- Use [meaningful anchor texts](https://www.futurehosting.com/blog/links-should-have-meaningful-anchor-text-heres-why/). + E.g., instead of writing something like `Read more about GitLab Issue Boards [here](LINK)`, + write `Read more about [GitLab Issue Boards](LINK)`. ## Images @@ -222,7 +110,7 @@ Inside the document: - Notes should be quoted with the word `Note:` being bold. Use this form: - ``` + ```md >**Note:** This is something to note. ``` @@ -234,25 +122,25 @@ Inside the document: If the note spans across multiple lines it's OK to split the line. -## New features +## Specific sections and terms -New features must be shipped with its accompanying documentation and the doc -reviewed by a technical writer. +To mention and/or reference specific terms in GitLab, please follow the styles +below. -### Mentioning GitLab versions and tiers +### GitLab versions and tiers - Every piece of documentation that comes with a new feature should declare the GitLab version that feature got introduced. Right below the heading add a note: - ``` + ```md > Introduced in GitLab 8.3. ``` - If possible every feature should have a link to the MR, issue, or epic that introduced it. The above note would be then transformed to: - ``` + ```md > [Introduced][ce-1242] in GitLab 8.3. ``` @@ -263,121 +151,41 @@ reviewed by a technical writer. the [paid tier](https://about.gitlab.com/handbook/marketing/product-marketing/#tiers) the feature is available in: - ``` + ```md > [Introduced][ee-1234] in [GitLab Starter](https://about.gitlab.com/products/) 8.3. ``` Otherwise, leave this mention out. -## References - -- **GitLab Restart:** - There are many cases that a restart/reconfigure of GitLab is required. To - avoid duplication, link to the special document that can be found in - [`doc/administration/restart_gitlab.md`][doc-restart]. Usually the text will - read like: - - ``` - Save the file and [reconfigure GitLab](../administration/restart_gitlab.md) - for the changes to take effect. - ``` - If the document you are editing resides in a place other than the GitLab CE/EE - `doc/` directory, instead of the relative link, use the full path: - `http://docs.gitlab.com/ce/administration/restart_gitlab.html`. - Replace `reconfigure` with `restart` where appropriate. - -## Installation guide - -- **Ruby:** - In [step 2 of the installation guide](../install/installation.md#2-ruby), - we install Ruby from source. Whenever there is a new version that needs to - be updated, remember to change it throughout the codeblock and also replace - the sha256sum (it can be found in the [downloads page][ruby-dl] of the Ruby - website). +### GitLab Restart -[ruby-dl]: https://www.ruby-lang.org/en/downloads/ "Ruby download website" - -## Changing document location - -Changing a document's location is not to be taken lightly. Remember that the -documentation is available to all installations under `help/` and not only to -GitLab.com or http://docs.gitlab.com. Make sure this is discussed with the -Documentation team beforehand. - -If you indeed need to change a document's location, do NOT remove the old -document, but rather replace all of its contents with a new line: - -``` -This document was moved to [another location](path/to/new_doc.md). -``` +There are many cases that a restart/reconfigure of GitLab is required. To +avoid duplication, link to the special document that can be found in +[`doc/administration/restart_gitlab.md`][doc-restart]. Usually the text will +read like: -where `path/to/new_doc.md` is the relative path to the root directory `doc/`. + ``` + Save the file and [reconfigure GitLab](../administration/restart_gitlab.md) + for the changes to take effect. + ``` ---- +If the document you are editing resides in a place other than the GitLab CE/EE +`doc/` directory, instead of the relative link, use the full path: +`http://docs.gitlab.com/ce/administration/restart_gitlab.html`. +Replace `reconfigure` with `restart` where appropriate. -For example, if you were to move `doc/workflow/lfs/lfs_administration.md` to -`doc/administration/lfs.md`, then the steps would be: +### Installation guide -1. Copy `doc/workflow/lfs/lfs_administration.md` to `doc/administration/lfs.md` -1. Replace the contents of `doc/workflow/lfs/lfs_administration.md` with: +**Ruby:** +In [step 2 of the installation guide](../install/installation.md#2-ruby), +we install Ruby from source. Whenever there is a new version that needs to +be updated, remember to change it throughout the codeblock and also replace +the sha256sum (it can be found in the [downloads page][ruby-dl] of the Ruby +website). - ``` - This document was moved to [another location](../../administration/lfs.md). - ``` - -1. Find and replace any occurrences of the old location with the new one. - A quick way to find them is to use `git grep`. First go to the root directory - where you cloned the `gitlab-ce` repository and then do: - - ``` - git grep -n "workflow/lfs/lfs_administration" - git grep -n "lfs/lfs_administration" - ``` - -NOTE: **Note:** -If the document being moved has any Disqus comments on it, there are extra steps -to follow documented just [below](#redirections-for-pages-with-disqus-comments). - -Things to note: - -- Since we also use inline documentation, except for the documentation itself, - the document might also be referenced in the views of GitLab (`app/`) which will - render when visiting `/help`, and sometimes in the testing suite (`spec/`). -- The above `git grep` command will search recursively in the directory you run - it in for `workflow/lfs/lfs_administration` and `lfs/lfs_administration` - and will print the file and the line where this file is mentioned. - You may ask why the two greps. Since we use relative paths to link to - documentation, sometimes it might be useful to search a path deeper. -- The `*.md` extension is not used when a document is linked to GitLab's - built-in help page, that's why we omit it in `git grep`. -- Use the checklist on the documentation MR description template. - -### Redirections for pages with Disqus comments - -If the documentation page being relocated already has any Disqus comments, -we need to preserve the Disqus thread. - -Disqus uses an identifier per page, and for docs.gitlab.com, the page identifier -is configured to be the page URL. Therefore, when we change the document location, -we need to preserve the old URL as the same Disqus identifier. - -To do that, add to the frontmatter the variable `redirect_from`, -using the old URL as value. For example, let's say I moved the document -available under `https://docs.gitlab.com/my-old-location/README.html` to a new location, -`https://docs.gitlab.com/my-new-location/index.html`. - -Into the **new document** frontmatter add the following: - -```yaml ---- -redirect_from: 'https://docs.gitlab.com/my-old-location/README.html' ---- -``` - -Note: it is necessary to include the file name in the `redirect_from` URL, -even if it's `index.html` or `README.html`. +[ruby-dl]: https://www.ruby-lang.org/en/downloads/ "Ruby download website" -## Configuration documentation for source and Omnibus installations +### Configuration documentation for source and Omnibus installations GitLab currently officially supports two installation methods: installations from source and Omnibus packages installations. @@ -394,7 +202,7 @@ When there is a list of steps to perform, usually that entails editing the configuration file and reconfiguring/restarting GitLab. In such case, follow the style below as a guide: -```` +```md **For Omnibus installations** 1. Edit `/etc/gitlab/gitlab.rb`: @@ -421,7 +229,7 @@ the style below as a guide: [reconfigure]: path/to/administration/restart_gitlab.md#omnibus-gitlab-reconfigure [restart]: path/to/administration/restart_gitlab.md#installations-from-source -```` +``` In this case: @@ -433,7 +241,7 @@ In this case: - different highlighting languages are used for each config in the code block - the [references](#references) guide is used for reconfigure/restart -## Fake tokens +### Fake tokens There may be times where a token is needed to demonstrate an API call using cURL or a secret variable used in CI. It is strongly advised not to use real @@ -456,7 +264,7 @@ You can use the following fake tokens as examples. | Health check token | `Tu7BgjR9qeZTEyRzGG2P` | | Request profile token | `7VgpS4Ax5utVD2esNstz` | -## API +### API Here is a list of must-have items. Use them in the exact order that appears on this document. Further explanation is given below. @@ -472,10 +280,10 @@ on this document. Further explanation is given below. - Every method must have a cURL example. - Every method must have a response body (in JSON format). -### Method description +#### Method description Use the following table headers to describe the methods. Attributes should -always be in code blocks using backticks (`). +always be in code blocks using backticks (``` ` ```). ``` | Attribute | Type | Required | Description | @@ -488,7 +296,7 @@ Rendered example: | --------- | ---- | -------- | ----------- | | `user` | string | yes | The GitLab username | -### cURL commands +#### cURL commands - Use `https://gitlab.example.com/api/v4/` as an endpoint. - Wherever needed use this personal access token: `9koXpg98eAheJpvBs5tK`. @@ -505,11 +313,11 @@ Rendered example: | `-X PUT` | Use this method when updating existing objects | | `-X DELETE` | Use this method when removing existing objects | -### cURL Examples +#### cURL Examples Below is a set of [cURL][] examples that you can use in the API documentation. -#### Simple cURL command +##### Simple cURL command Get the details of a group: @@ -517,7 +325,7 @@ Get the details of a group: curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/gitlab-org ``` -#### cURL example with parameters passed in the URL +##### cURL example with parameters passed in the URL Create a new project under the authenticated user's namespace: @@ -525,7 +333,7 @@ Create a new project under the authenticated user's namespace: curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects?name=foo" ``` -#### Post data using cURL's --data +##### Post data using cURL's --data Instead of using `-X POST` and appending the parameters to the URI, you can use cURL's `--data` option. The example below will create a new project `foo` under @@ -535,7 +343,7 @@ the authenticated user's namespace. curl --data "name=foo" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects" ``` -#### Post data using JSON content +##### Post data using JSON content > **Note:** In this example we create a new group. Watch carefully the single and double quotes. @@ -544,7 +352,7 @@ and double quotes. curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' https://gitlab.example.com/api/v4/groups ``` -#### Post data using form-data +##### Post data using form-data Instead of using JSON or urlencode you can use multipart/form-data which properly handles data encoding: @@ -556,7 +364,7 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "title The above example is run by and administrator and will add an SSH public key titled ssh-key to user's account which has an id of 25. -#### Escape special characters +##### Escape special characters Spaces or slashes (`/`) may sometimes result to errors, thus it is recommended to escape them when possible. In the example below we create a new issue which @@ -569,7 +377,7 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitl Use `%2F` for slashes (`/`). -#### Pass arrays to API calls +##### Pass arrays to API calls The GitLab API sometimes accepts arrays of strings or integers. For example, to restrict the sign-up e-mail domains of a GitLab instance to `*.example.com` and @@ -584,6 +392,3 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "domain [gfm]: http://docs.gitlab.com/ce/user/markdown.html#newlines "GitLab flavored markdown documentation" [ce-1242]: https://gitlab.com/gitlab-org/gitlab-ce/issues/1242 [doc-restart]: ../administration/restart_gitlab.md "GitLab restart documentation" -[ce-3349]: https://gitlab.com/gitlab-org/gitlab-ce/issues/3349 "Documentation restructure" -[graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/d8d39f4a87b90fb9ae89ca12dc565347b4900d5e/production/resources/gitlab-map.graffle -[gitlab-map]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index 1eb90c30ebd..fea92e740cb 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -360,27 +360,15 @@ Instead place EE specs in the `ee/spec` folder. ## JavaScript code in `assets/javascripts/` -To separate EE-specific JS-files we can also move the files into an `ee` folder. +To separate EE-specific JS-files we should also move the files into an `ee` folder. For example there can be an `app/assets/javascripts/protected_branches/protected_branches_bundle.js` and an EE counterpart `ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js`. -That way we can create a separate webpack bundle in `webpack.config.js`: - -```javascript - protected_branches: '~/protected_branches', - ee_protected_branches: 'ee/protected_branches/protected_branches_bundle.js', -``` - -With the separate bundle in place, we can decide which bundle to load inside the -view, using the `page_specific_javascript_bundle_tag` helper. - -```haml -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('protected_branches') -``` +See the frontend guide [performance section](./fe_guide/performance.md) for +information on managing page-specific javascript within EE. ## SCSS code in `assets/stylesheets` diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index 12dfc10812b..2280cf79f86 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -14,8 +14,8 @@ support through [webpack][webpack]. We also utilize [webpack][webpack] to handle the bundling, minification, and compression of our assets. -Working with our frontend assets requires Node (v4.3 or greater) and Yarn -(v0.17 or greater). You can find information on how to install these on our +Working with our frontend assets requires Node (v6.0 or greater) and Yarn +(v1.2 or greater). You can find information on how to install these on our [installation guide][install]. [jQuery][jquery] is used throughout the application's JavaScript, with diff --git a/doc/development/fe_guide/performance.md b/doc/development/fe_guide/performance.md index 98e43931a02..1320efaf767 100644 --- a/doc/development/fe_guide/performance.md +++ b/doc/development/fe_guide/performance.md @@ -23,7 +23,7 @@ controlled by the server. 1. The backend code will most likely be using etags. You do not and should not check for status `304 Not Modified`. The browser will transform it for you. -### Lazy Loading +### Lazy Loading Images To improve the time to first render we are using lazy loading for images. This works by setting the actual image source on the `data-src` attribute. After the HTML is rendered and JavaScript is loaded, @@ -47,41 +47,103 @@ properties once, and handle the actual animation with transforms. ## Reducing Asset Footprint -### Page-specific JavaScript +### Universal code -Certain pages may require the use of a third party library, such as [d3][d3] for -the User Activity Calendar and [Chart.js][chartjs] for the Graphs pages. These -libraries increase the page size significantly, and impact load times due to -bandwidth bottlenecks and the browser needing to parse more JavaScript. - -In cases where libraries are only used on a few specific pages, we use -"page-specific JavaScript" to prevent the main `main.js` file from -becoming unnecessarily large. - -Steps to split page-specific JavaScript from the main `main.js`: - -1. Create a directory for the specific page(s), e.g. `graphs/`. -1. In that directory, create a `namespace_bundle.js` file, e.g. `graphs_bundle.js`. -1. Add the new "bundle" file to the list of entry files in `config/webpack.config.js`. - - For example: `graphs: './graphs/graphs_bundle.js',`. -1. Move code reliant on these libraries into the `graphs` directory. -1. In `graphs_bundle.js` add CommonJS `require('./path_to_some_component.js');` statements to load any other files in this directory. Make sure to use relative urls. -1. In the relevant views, add the scripts to the page with the following: - -```haml -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'lib_chart' - = webpack_bundle_tag 'graphs' -``` +Code that is contained within `main.js` and `commons/index.js` are loaded and +run on _all_ pages. **DO NOT ADD** anything to these files unless it is truly +needed _everywhere_. These bundles include ubiquitous libraries like `vue`, +`axios`, and `jQuery`, as well as code for the main navigation and sidebar. +Where possible we should aim to remove modules from these bundles to reduce our +code footprint. + +### Page-specific JavaScript -The above loads `chart.js` and `graphs_bundle.js` for this page only. `chart.js` -is separated from the bundle file so it can be cached separately from the bundle -and reused for other pages that also rely on the library. For an example, see -[this Haml file][page-specific-js-example]. +Webpack has been configured to automatically generate entry point bundles based +on the file structure within `app/assets/javascripts/pages/*`. The directories +within the `pages` directory correspond to Rails controllers and actions. These +auto-generated bundles will be automatically included on the corresponding +pages. + +For example, if you were to visit [gitlab.com/gitlab-org/gitlab-ce/issues](https://gitlab.com/gitlab-org/gitlab-ce/issues), +you would be accessing the `app/controllers/projects/issues_controller.rb` +controller with the `index` action. If a corresponding file exists at +`pages/projects/issues/index/index.js`, it will be compiled into a webpack +bundle and included on the page. + +> **Note:** Previously we had encouraged the use of +> `content_for :page_specific_javascripts` within haml files, along with +> manually generated webpack bundles. However under this new system you should +> not ever need to manually add an entry point to the `webpack.config.js` file. + +> **Tip:** +> If you are unsure what controller and action corresponds to a given page, you +> can find this out by inspecting `document.body.dataset.page` within your +> browser's developer console while on any page within gitlab. + +#### Important Considerations: + +- **Keep Entry Points Lite:** + Page-specific javascript entry points should be as lite as possible. These + files are exempt from unit tests, and should be used primarily for + instantiation and dependency injection of classes and methods that live in + modules outside of the entry point script. Just import, read the DOM, + instantiate, and nothing else. + +- **Entry Points May Be Asynchronous:** + _DO NOT ASSUME_ that the DOM has been fully loaded and available when an + entry point script is run. If you require that some code be run after the + DOM has loaded, you should attach an event handler to the `DOMContentLoaded` + event with: + + ```javascript + import initMyWidget from './my_widget'; + + document.addEventListener('DOMContentLoaded', () => { + initMyWidget(); + }); + ``` + +- **Supporting Module Placement:** + - If a class or a module is _specific to a particular route_, try to locate + it close to the entry point it will be used. For instance, if + `my_widget.js` is only imported within `pages/widget/show/index.js`, you + should place the module at `pages/widget/show/my_widget.js` and import it + with a relative path (e.g. `import initMyWidget from './my_widget';`). + + - If a class or module is _used by multiple routes_, place it within a + shared directory at the closest common parent directory for the entry + points that import it. For example, if `my_widget.js` is imported within + both `pages/widget/show/index.js` and `pages/widget/run/index.js`, then + place the module at `pages/widget/shared/my_widget.js` and import it with + a relative path if possible (e.g. `../shared/my_widget`). + +- **Enterprise Edition Caveats:** + For GitLab Enterprise Edition, page-specific entry points will override their + Community Edition counterparts with the same name, so if + `ee/app/assets/javascripts/pages/foo/bar/index.js` exists, it will take + precedence over `app/assets/javascripts/pages/foo/bar/index.js`. If you want + to minimize duplicate code, you can import one entry point from the other. + This is not done automatically to allow for flexibility in overriding + functionality. ### Code Splitting -> *TODO* flesh out this section once webpack is ready for code-splitting +For any code that does not need to be run immediately upon page load, (e.g. +modals, dropdowns, and other behaviors that can be lazy-loaded), you can split +your module into asynchronous chunks with dynamic import statements. These +imports return a Promise which will be resolved once the script has loaded: + +```javascript +import(/* webpackChunkName: 'emoji' */ '~/emoji') + .then(/* do something */) + .catch(/* report error */) +``` + +Please try to use `webpackChunkName` when generating these dynamic imports as +it will provide a deterministic filename for the chunk which can then be cached +the browser across GitLab versions. + +More information is available in [webpack's code splitting documentation](https://webpack.js.org/guides/code-splitting/#dynamic-imports). ### Minimizing page size @@ -95,7 +157,8 @@ General tips: - Prefer font formats with better compression, e.g. WOFF2 is better than WOFF, which is better than TTF. - Compress and minify assets wherever possible (For CSS/JS, Sprockets and webpack do this for us). - If some functionality can reasonably be achieved without adding extra libraries, avoid them. -- Use page-specific JavaScript as described above to dynamically load libraries that are only needed on certain pages. +- Use page-specific JavaScript as described above to load libraries that are only needed on certain pages. +- Use code-splitting dynamic imports wherever possible to lazy-load code that is not needed initially. - [High Performance Animations][high-perf-animations] ------- @@ -112,8 +175,5 @@ General tips: [pagespeed-insights]: https://developers.google.com/speed/pagespeed/insights/ [google-devtools-profiling]: https://developers.google.com/web/tools/chrome-devtools/profile/?hl=en [browser-diet]: https://browserdiet.com/ -[d3]: https://d3js.org/ -[chartjs]: http://www.chartjs.org/ -[page-specific-js-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/13bb9ed77f405c5f6ee4fdbc964ecf635c9a223f/app/views/projects/graphs/_head.html.haml#L6-8 [high-perf-animations]: https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/ [flip]: https://aerotwist.com/blog/flip-your-animations/ diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index b732cc65b73..960eabd5538 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -17,10 +17,13 @@ are very appreciative of the work done by translators and proofreaders! - French - Rémy Coutable - [GitLab](https://gitlab.com/rymai), [Crowdin](https://crowdin.com/profile/rymai) - German +- Indonesian + - Ahmad Naufal Mukhtar - [GitLab](https://gitlab.com/anaufalm), [Crowdin](https://crowdin.com/profile/anaufalm) - Italian - Paolo Falomo - [GitLab](https://gitlab.com/paolofalomo), [Crowdin](https://crowdin.com/profile/paolo.falomo) - Japanese - Korean + - Chang-Ho Cha - [GitLab](https://gitlab.com/changho-cha), [Crowdin](https://crowdin.com/profile/zzazang) - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve) - Polish - Filip Mech - [GitLab](https://gitlab.com/mehenz), [Crowdin](https://crowdin.com/profile/mehenz) diff --git a/doc/development/new_fe_guide/development/security.md b/doc/development/new_fe_guide/development/security.md index debda7de0c6..5bb38f17988 100644 --- a/doc/development/new_fe_guide/development/security.md +++ b/doc/development/new_fe_guide/development/security.md @@ -1,3 +1,14 @@ # Security -> TODO: Add content +## Avoid inline scripts and styles + +Inline scripts and styles should be avoided in almost all cases. In an effort to protect users from [XSS vulnerabilities](https://en.wikipedia.org/wiki/Cross-site_scripting), we will be disabling inline scripts using Content Security Policy. + +## Including external resources + +External fonts, CSS, and JavaScript should never be used with the exception of Google Analytics and Piwik - and only when the instance has enabled it. Assets should always be hosted and served locally from the GitLab instance. Embedded resources via `iframes` should never be used except in certain circumstances such as with ReCaptcha, which cannot be used without an `iframe`. + +## Resources for security testing + +- [Mozilla's HTTP Observatory CLI](https://github.com/mozilla/http-observatory-cli) +- [Qualys SSL Labs Server Test](https://www.ssllabs.com/ssltest/analyze.html) diff --git a/doc/development/new_fe_guide/index.md b/doc/development/new_fe_guide/index.md index 08c6a266e7f..78931defa24 100644 --- a/doc/development/new_fe_guide/index.md +++ b/doc/development/new_fe_guide/index.md @@ -19,7 +19,7 @@ Guidance on topics related to development. Learn about all the dependencies that make up our frontend, including some of our own custom built libraries. -## [Style](style/index.md) +## [Style guides](style/index.md) Style guides to keep our code consistent. diff --git a/doc/development/new_fe_guide/principles.md b/doc/development/new_fe_guide/principles.md index 2126d202a7e..0af5f506a91 100644 --- a/doc/development/new_fe_guide/principles.md +++ b/doc/development/new_fe_guide/principles.md @@ -1,3 +1,35 @@ # Principles -> TODO: Add principles +These principles will ensure that your frontend contribution starts off in the right direction. + +## Discuss architecture before implementation + +Discuss your architecture design in an issue before writing code. This helps decrease the review time and also provides good practice for writing and thinking about system design. + +## Be consistent + +There are multiple ways of writing code to accomplish the same results. We should be as consistent as possible in how we write code across our codebases. This will make it more easier us to maintain our code across GitLab. + +## Enhance progressively + +Whenever you see with existing code that does not follow our current style guide, update it proactively. Refrain from changing everything but each merge request should progressively enhance our codebase and reduce technical debt. + +## When to use Vue + +- Use Vue for feature that make use of heavy DOM manipulation +- Use Vue for reusable components + +## When to use jQuery + +- Use jQuery to interact with Bootstrap JavaScript components +- Avoid jQuery when a better alternative exists. We are slowly moving away from it [#43559][jquery-future] + +## Mixing Vue and jQuery + +- Mixing Vue and jQuery is not recommended. +- If you need to use a specific jQuery plugin in Vue, [create a wrapper around it][select2]. +- It is acceptable for Vue to listen to existing jQuery events using jQuery event listeners. +- It is not recommended to add new jQuery events for Vue to interact with jQuery. + +[jquery-future]: https://gitlab.com/gitlab-org/gitlab-ce/issues/43559 +[select2]: https://vuejs.org/v2/examples/select2.html diff --git a/doc/development/new_fe_guide/style/html.md b/doc/development/new_fe_guide/style/html.md index 5489def5d6e..2d5b7d048ab 100644 --- a/doc/development/new_fe_guide/style/html.md +++ b/doc/development/new_fe_guide/style/html.md @@ -1,3 +1,53 @@ # HTML style guide -> TODO: Add content +## Buttons + +<a name="button-type"></a><a name="1.1"></a> +- [1.1](#button-type) **Use button type** Button tags requires a `type` attribute according to the [W3C HTML specification][button-type-spec]. + +``` +// bad +<button></button> + +// good +<button type="button"></button> +``` + +<a name="button-role"></a><a name="1.2"></a> +- [1.2](#button-role) **Use button role for non buttons** If an HTML element has an onClick handler but is not a button, it should have `role="button"`. This is more [accessible][button-role-accessible]. + +``` +// bad +<div onClick="doSomething"></div> + +// good +<div role="button" onClick="doSomething"></div> +``` + +## Links + +<a name="blank-links"></a><a name="2.1"></a> +- [2.1](#blank-links) **Use rel for target blank** Use `rel="noopener noreferrer"` whenever your links open in a new window i.e. `target="_blank"`. This prevents [the following][jitbit-target-blank] security vulnerability documented by JitBit + +``` +// bad +<a href="url" target="_blank"></a> + +// good +<a href="url" target="_blank" rel="noopener noreferrer"></a> +``` + +<a name="fake-links"></a><a name="2.2"></a> +- [2.2](#fake-links) **Do not use fake links** Use a button tag if a link only invokes JavaScript click event handlers. This is more semantic. + +``` +// bad +<a class="js-do-something" href="#"></a> + +// good +<button class="js-do-something" type="button"></button> +``` + +[button-type-spec]: https://www.w3.org/TR/2011/WD-html5-20110525/the-button-element.html#dom-button-type +[button-role-accessible]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_button_role +[jitbit-target-blank]: https://www.jitbit.com/alexblog/256-targetblank---the-most-underestimated-vulnerability-ever/ diff --git a/doc/development/new_fe_guide/style/index.md b/doc/development/new_fe_guide/style/index.md index d2d576b3b46..335d9e66240 100644 --- a/doc/development/new_fe_guide/style/index.md +++ b/doc/development/new_fe_guide/style/index.md @@ -1,4 +1,4 @@ -# Style +# Style guides ## [HTML style guide](html.md) @@ -7,3 +7,9 @@ ## [JavaScript style guide](javascript.md) ## [Vue style guide](vue.md) + +# Tooling + +## [Prettier](prettier.md) + +Our code is automatically formatted with [Prettier](https://prettier.io) to follow our guidelines. diff --git a/doc/development/new_fe_guide/style/prettier.md b/doc/development/new_fe_guide/style/prettier.md new file mode 100644 index 00000000000..eb18189282b --- /dev/null +++ b/doc/development/new_fe_guide/style/prettier.md @@ -0,0 +1,45 @@ +# Formatting with Prettier + +Our code is automatically formatted with [Prettier](https://prettier.io) to follow our style guides. Prettier is taking care of formatting .js, .vue, and .scss files based on the standard prettier rules. You can find all settings for Prettier in `.prettierrc`. + +## Editor + +The easiest way to include prettier in your workflow is by setting up your preferred editor (all major editors are supported) accordingly. We suggest setting up prettier to run automatically when each file is saved. Find [here](https://prettier.io/docs/en/editors.html) the best way to set it up in your preferred editor. + +Please take care that you only let Prettier format the same file types as the global Yarn script does (.js, .vue, and .scss). In VSCode by example you can easily exclude file formats in your settings file: + +``` + "prettier.disableLanguages": [ + "json", + "markdown" + ], +``` + +## Yarn Script + +The following yarn scripts are available to do global formatting: + +``` +yarn prettier-staged-save +``` + +Updates all currently staged files (based on `git diff`) with Prettier and saves the needed changes. + +``` +yarn prettier-staged +``` +Checks all currently staged files (based on `git diff`) with Prettier and log which files would need manual updating to the console. + +``` +yarn prettier-all +``` + +Checks all files with Prettier and logs which files need manual updating to the console. + +``` +yarn prettier-all-save +``` + +Formats all files in the repository with Prettier. (This should only be used to test global rule updates otherwise you would end up with huge MR's). + +The source of these Yarn scripts can be found in `/scripts/frontend/prettier.js`. 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/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 40c21e5355c..d6a13e7483a 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -1,13 +1,9 @@ -# Writing documentation +# GitLab Documentation guidelines - **General Documentation**: written by the [developers responsible by creating features](#contributing-to-docs). Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers. - **[Technical Articles](#technical-articles)**: written by any [GitLab Team](https://about.gitlab.com/team/) member, GitLab contributors, or [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/). - **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs in the same merge request containing code. They gather all resources for that topic in a single page (user and admin documentation, articles, and third-party docs). -## Documentation style guidelines - -All the docs follow the same [styleguide](doc_styleguide.md). - ## Contributing to docs Whenever a feature is changed, updated, introduced, or deprecated, the merge @@ -29,23 +25,16 @@ Whenever you submit a merge request for the documentation, use the documentation Please check the [documentation workflow](https://about.gitlab.com/handbook/product/technical-writing/workflow/) before getting started. -### Documentation directory structure - -The documentation is structured based on the GitLab UI structure itself, -separated by [`user`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/user), -[`administrator`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/administration), and [`contributor`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/development). - -To learn where to place a new document, check the [documentation style guide](doc_styleguide.md#location-and-naming-of-documents). +## Documentation structure -In order to have a [solid site structure](https://searchengineland.com/seo-benefits-developing-solid-site-structure-277456) for our documentation, -all docs should be linked. Every new document should be cross-linked to its related documentation, and linked from its topic-related index, when existent. +- Overview and use cases: what it is, why it is necessary, why one would use it +- Requirements: what do we need to get started +- Tutorial: how to set it up, how to use it -The directories `/workflow/`, `/gitlab-basics/`, `/university/`, and `/articles/` have -been deprecated and the majority their docs have been moved to their correct location -in small iterations. Don't create new docs in these folders. +Always link a new document from its topic-related index, otherwise, it will +not be included it in the documentation site search. -To move a document from its location to another directory, read the section -[changing document location](doc_styleguide.md#changing-document-location) of the doc style guide. +_Note: to be extended._ ### Feature overview and use cases @@ -75,16 +64,169 @@ overview there. > **Overview** and **use cases** are required to **every** Enterprise Edition feature, and for every **major** feature present in Community Edition. -### Markdown +## Markdown and styles Currently GitLab docs use Redcarpet as [markdown](../user/markdown.md) engine, but there's an [open discussion](https://gitlab.com/gitlab-com/gitlab-docs/issues/50) for implementing Kramdown in the near future. -### Previewing locally +All the docs follow the [documentation style guidelines](doc_styleguide.md). -To preview your changes to documentation locally, please follow -this [development guide](https://gitlab.com/gitlab-com/gitlab-docs/blob/master/README.md#development). +## Documentation directory structure + +The documentation is structured based on the GitLab UI structure itself, +separated by [`user`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/user), +[`administrator`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/administration), and [`contributor`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/development). + +In order to have a [solid site structure](https://searchengineland.com/seo-benefits-developing-solid-site-structure-277456) for our documentation, +all docs should be linked. Every new document should be cross-linked to its related documentation, and linked from its topic-related index, when existent. + +The directories `/workflow/`, `/gitlab-basics/`, `/university/`, and `/articles/` have +been deprecated and the majority their docs have been moved to their correct location +in small iterations. Please don't create new docs in these folders. + +### Location and naming documents + +The documentation hierarchy can be vastly improved by providing a better layout +and organization of directories. + +Having a structured document layout, we will be able to have meaningful URLs +like `docs.gitlab.com/user/project/merge_requests/index.html`. With this pattern, +you can immediately tell that you are navigating a user related documentation +and is about the project and its merge requests. + +Do not create summaries of similar types of content (e.g. an index of all articles, videos, etc.), +rather organize content by its subject (e.g. everything related to CI goes together) +and cross-link between any related content. + +The table below shows what kind of documentation goes where. + +| Directory | What belongs here | +| --------- | -------------- | +| `doc/user/` | User related documentation. Anything that can be done within the GitLab UI goes here including `/admin`. | +| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. The admin settings that can be accessed via GitLab's interface go under `doc/user/admin_area/`. | +| `doc/api/` | API related documentation. | +| `doc/development/` | Documentation related to the development of GitLab. Any styleguides should go here. | +| `doc/legal/` | Legal documents about contributing to GitLab. | +| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). | +| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. | +| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`): all resources for that topic (user and admin documentation, articles, and third-party docs) | + +--- + +**General rules:** + +1. The correct naming and location of a new document, is a combination + of the relative URL of the document in question and the GitLab Map design + that is used for UX purposes ([source][graffle], [image][gitlab-map]). +1. When creating a new document and it has more than one word in its name, + make sure to use underscores instead of spaces or dashes (`-`). For example, + a proper naming would be `import_projects_from_github.md`. The same rule + applies to images. +1. Start a new directory with an `index.md` file. +1. There are four main directories, `user`, `administration`, `api` and `development`. +1. The `doc/user/` directory has five main subdirectories: `project/`, `group/`, + `profile/`, `dashboard/` and `admin_area/`. + 1. `doc/user/project/` should contain all project related documentation. + 1. `doc/user/group/` should contain all group related documentation. + 1. `doc/user/profile/` should contain all profile related documentation. + Every page you would navigate under `/profile` should have its own document, + i.e. `account.md`, `applications.md`, `emails.md`, etc. + 1. `doc/user/dashboard/` should contain all dashboard related documentation. + 1. `doc/user/admin_area/` should contain all admin related documentation + describing what can be achieved by accessing GitLab's admin interface + (_not to be confused with `doc/administration` where server access is + required_). + 1. Every category under `/admin/application_settings` should have its + own document located at `doc/user/admin_area/settings/`. For example, + the **Visibility and Access Controls** category should have a document + located at `doc/user/admin_area/settings/visibility_and_access_controls.md`. +1. The `doc/topics/` directory holds topic-related technical content. Create + `doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary. + General user- and admin- related documentation, should be placed accordingly. + +If you are unsure where a document should live, you can ping `@axil` or `@marcia` in your +merge request. + +### Changing document location + +Changing a document's location is not to be taken lightly. Remember that the +documentation is available to all installations under `help/` and not only to +GitLab.com or http://docs.gitlab.com. Make sure this is discussed with the +Documentation team beforehand. + +If you indeed need to change a document's location, do NOT remove the old +document, but rather replace all of its contents with a new line: + +``` +This document was moved to [another location](path/to/new_doc.md). +``` + +where `path/to/new_doc.md` is the relative path to the root directory `doc/`. + +--- + +For example, if you were to move `doc/workflow/lfs/lfs_administration.md` to +`doc/administration/lfs.md`, then the steps would be: + +1. Copy `doc/workflow/lfs/lfs_administration.md` to `doc/administration/lfs.md` +1. Replace the contents of `doc/workflow/lfs/lfs_administration.md` with: + + ``` + This document was moved to [another location](../../administration/lfs.md). + ``` + +1. Find and replace any occurrences of the old location with the new one. + A quick way to find them is to use `git grep`. First go to the root directory + where you cloned the `gitlab-ce` repository and then do: -### Testing + ``` + git grep -n "workflow/lfs/lfs_administration" + git grep -n "lfs/lfs_administration" + ``` + +NOTE: **Note:** +If the document being moved has any Disqus comments on it, there are extra steps +to follow documented just [below](#redirections-for-pages-with-disqus-comments). + +Things to note: + +- Since we also use inline documentation, except for the documentation itself, + the document might also be referenced in the views of GitLab (`app/`) which will + render when visiting `/help`, and sometimes in the testing suite (`spec/`). +- The above `git grep` command will search recursively in the directory you run + it in for `workflow/lfs/lfs_administration` and `lfs/lfs_administration` + and will print the file and the line where this file is mentioned. + You may ask why the two greps. Since we use relative paths to link to + documentation, sometimes it might be useful to search a path deeper. +- The `*.md` extension is not used when a document is linked to GitLab's + built-in help page, that's why we omit it in `git grep`. +- Use the checklist on the documentation MR description template. + +### Redirections for pages with Disqus comments + +If the documentation page being relocated already has any Disqus comments, +we need to preserve the Disqus thread. + +Disqus uses an identifier per page, and for docs.gitlab.com, the page identifier +is configured to be the page URL. Therefore, when we change the document location, +we need to preserve the old URL as the same Disqus identifier. + +To do that, add to the frontmatter the variable `redirect_from`, +using the old URL as value. For example, let's say I moved the document +available under `https://docs.gitlab.com/my-old-location/README.html` to a new location, +`https://docs.gitlab.com/my-new-location/index.html`. + +Into the **new document** frontmatter add the following: + +```yaml +--- +redirect_from: 'https://docs.gitlab.com/my-old-location/README.html' +--- +``` + +Note: it is necessary to include the file name in the `redirect_from` URL, +even if it's `index.html` or `README.html`. + +## Testing We treat documentation as code, thus have implemented some testing. Currently, the following tests are in place: @@ -103,7 +245,7 @@ Currently, the following tests are in place: Submitting an EE-equivalent merge request cherry-picking all commits from CE to EE is essential to avoid them. -### Branch naming +## Branch naming If your contribution contains **only** documentation changes, you can speed up the CI process by following some branch naming conventions. You have three @@ -118,7 +260,7 @@ choices: If your branch name matches any of the above, it will run only the docs tests. If it doesn't, the whole test suite will run (including docs). -### Merge requests for GitLab documentation +## Merge requests for GitLab documentation Before getting started, make sure you read the introductory section "[contributing to docs](#contributing-to-docs)" above and the @@ -137,7 +279,7 @@ frozen or released, use the label `Pick into X.Y` to get it merged into the correct release. Avoid picking into a past release as much as you can, as it increases the work of the release managers. -#### Cherry-picking from CE to EE +### Cherry-picking from CE to EE As we have the `master` branch of CE merged into EE once a day, it's common to run into merge conflicts. To avoid them, we [test for merge conflicts against EE](#testing) @@ -161,7 +303,10 @@ a couple more commits to the EE branch, but ask the reviewer to review the EE me additionally to the CE MR. If there are many EE-only changes though, start a new MR to EE only. -### Previewing the changes live +## Previewing the changes live + +To preview your changes to documentation locally, please follow +this [development guide](https://gitlab.com/gitlab-com/gitlab-docs/blob/master/README.md#development). If you want to preview the doc changes of your merge request live, you can use the manual `review-docs-deploy` job in your merge request. You will need at @@ -221,7 +366,7 @@ working on. If you don't, the remote docs branch won't be removed either, and the server where the Review Apps are hosted will eventually be out of disk space. -#### Technical aspects +### Technical aspects If you want to know the hot details, here's what's really happening: @@ -256,6 +401,74 @@ The following GitLab features are used among others: - [Artifacts](../ci/yaml/README.md#artifacts) - [Specific Runner](../ci/runners/README.md#locking-a-specific-runner-from-being-enabled-for-other-projects) +## GitLab `/help` + +Every GitLab instance includes the documentation, which is available from `/help` +(`http://my-instance.com/help`), e.g., <https://gitlab.com/help>. + +When you're building a new feature, you may need to link the documentation +from GitLab, the application. This is normally done in files inside the +`app/views/` directory with the help of the `help_page_path` helper method. + +In its simplest form, the HAML code to generate a link to the `/help` page is: + +```haml += link_to 'Help page', help_page_path('user/permissions') +``` + +The `help_page_path` contains the path to the document you want to link to with +the following conventions: + +- it is relative to the `doc/` directory in the GitLab repository +- the `.md` extension must be omitted +- it must not end with a slash (`/`) + +Below are some special cases where should be used depending on the context. +You can combine one or more of the following: + +1. **Linking to an anchor link.** Use `anchor` as part of the `help_page_path` + method: + + ```haml + = link_to 'Help page', help_page_path('user/permissions', anchor: 'anchor-link') + ``` + +1. **Opening links in a new tab.** This should be the default behavior: + + ```haml + = link_to 'Help page', help_page_path('user/permissions'), target: '_blank' + ``` + +1. **Linking to a circle icon.** Usually used in settings where a long + description cannot be used, like near checkboxes. You can basically use + any font awesome icon, but prefer the `question-circle`: + + ```haml + = link_to icon('question-circle'), help_page_path('user/permissions') + ``` + +1. **Using a button link.** Useful in places where text would be out of context + with the rest of the page layout: + + ```haml + = link_to 'Help page', help_page_path('user/permissions'), class: 'btn btn-info' + ``` + +1. **Using links inline of some text.** + + ```haml + Description to #{link_to 'Help page', help_page_path('user/permissions')}. + ``` + +1. **Adding a period at the end of the sentence.** Useful when you don't want + the period to be part of the link: + + ```haml + = succeed '.' do + Learn more in the + = link_to 'Help page', help_page_path('user/permissions') + ``` + ## General Documentation vs Technical Articles ### General documentation @@ -270,7 +483,7 @@ They are topic-related documentation, written with an user-friendly approach and A technical article guides users and/or admins to achieve certain objectives (within guides and tutorials), or provide an overview of that particular topic or feature (within technical overviews). It can also describe the use, implementation, or integration of third-party tools with GitLab. -They should be placed in a new directory named `/article-title/index.md` under a topic-related folder, and their images should be placed in `/article-title/img/`. For example, a new article on GitLab Pages should be placed in `doc/user/project/pages/article-title/` and a new article on GitLab CI/CD should be placed in `doc/ci/article-title/`. +They should be placed in a new directory named `/article-title/index.md` under a topic-related folder, and their images should be placed in `/article-title/img/`. For example, a new article on GitLab Pages should be placed in `doc/user/project/pages/article-title/` and a new article on GitLab CI/CD should be placed in `doc/ci/examples/article-title/`. #### Types of Technical Articles @@ -324,3 +537,5 @@ date: 2017-02-01 Use the [writing method](https://about.gitlab.com/handbook/product/technical-writing/#writing-method) defined by the Technical Writing team. +[gitlab-map]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png +[graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/d8d39f4a87b90fb9ae89ca12dc565347b4900d5e/production/resources/gitlab-map.graffle diff --git a/doc/downgrade_ee_to_ce/README.md b/doc/downgrade_ee_to_ce/README.md index 75bae324585..f656057e3da 100644 --- a/doc/downgrade_ee_to_ce/README.md +++ b/doc/downgrade_ee_to_ce/README.md @@ -70,7 +70,7 @@ To downgrade an Omnibus installation, it is sufficient to install the Community Edition package on top of the currently installed one. You can do this manually, by directly [downloading the package](https://packages.gitlab.com/gitlab/gitlab-ce) you need, or by adding our CE package repository and following the -[CE installation instructions](https://about.gitlab.com/downloads/). +[CE installation instructions](https://about.gitlab.com/installation/?version=ce). **Source Installation** diff --git a/doc/install/installation.md b/doc/install/installation.md index 6eb767b00b3..1abbfd78738 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -162,13 +162,14 @@ page](https://golang.org/dl). ## 4. Node -Since GitLab 8.17, GitLab requires the use of node >= v4.3.0 to compile -javascript assets, and yarn >= v0.17.0 to manage javascript dependencies. -In many distros the versions provided by the official package repositories -are out of date, so we'll need to install through the following commands: - - # install node v7.x - curl --location https://deb.nodesource.com/setup_7.x | sudo bash - +Since GitLab 8.17, GitLab requires the use of Node to compile javascript +assets, and Yarn to manage javascript dependencies. The current minimum +requirements for these are node >= v6.0.0 and yarn >= v1.2.0. In many distros +the versions provided by the official package repositories are out of date, so +we'll need to install through the following commands: + + # install node v8.x + curl --location https://deb.nodesource.com/setup_8.x | sudo bash - sudo apt-get install -y nodejs curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - diff --git a/doc/install/openshift_and_gitlab/index.md b/doc/install/openshift_and_gitlab/index.md index 1f46ee4c1ea..e6ccfccd33f 100644 --- a/doc/install/openshift_and_gitlab/index.md +++ b/doc/install/openshift_and_gitlab/index.md @@ -464,7 +464,9 @@ bother us. In any case, it is something to keep in mind when deploying GitLab on a production cluster. In order to deploy GitLab on a production cluster, you will need to assign the -GitLab service account to the `anyuid` Security Context. +GitLab service account to the `anyuid` [Security Context Constraints][scc]. + +For OpenShift v3.0, you will need to do this manually: 1. Edit the Security Context: ```sh @@ -477,6 +479,12 @@ GitLab service account to the `anyuid` Security Context. 1. Save and exit the editor +For OpenShift v3.1 and above, you can do: + +```sh +oc adm policy add-scc-to-user anyuid system:serviceaccount:gitlab:gitlab-ce-user +``` + ## Conclusion By now, you should have an understanding of the basic OpenShift Origin concepts @@ -513,3 +521,4 @@ PaaS and managing your applications with the ease of containers. [autoscaling]: https://docs.openshift.org/latest/dev_guide/pod_autoscaling.html "Documentation - Autoscale" [basic-cli]: https://docs.openshift.org/latest/cli_reference/basic_cli_operations.html "Documentation - Basic CLI operations" [openshift-docs]: https://docs.openshift.org "OpenShift documentation" +[scc]: https://docs.openshift.org/latest/admin_guide/manage_scc.html "Documentation - Managing Security Context Constraints"
\ No newline at end of file diff --git a/doc/integration/slash_commands.md b/doc/integration/slash_commands.md index 36a8844e953..7d73026a6c6 100644 --- a/doc/integration/slash_commands.md +++ b/doc/integration/slash_commands.md @@ -15,9 +15,10 @@ Taking the trigger term as `project-name`, the commands are: | `/project-name issue new <title> <shift+return> <description>` | Creates a new issue with title `<title>` and description `<description>` | | `/project-name issue show <id>` | Shows the issue with id `<id>` | | `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` | +| `/project-name issue move <id> to <project>` | Moves issue ID `<id>` to `<project>` | | `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment | -Note that if you are using the [GitLab Slack application](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html) for +Note that if you are using the [GitLab Slack application](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html) for your GitLab.com projects, you need to [add the `gitlab` keyword at the beginning of the command](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html#usage). ## Issue commands diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index ec091549c05..4dc3adc1441 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -20,7 +20,7 @@ project in an easy and automatic way: 1. [Auto Test](#auto-test) 1. [Auto Code Quality](#auto-code-quality) 1. [Auto SAST (Static Application Security Testing)](#auto-sast) -1. [Auto SAST for Docker images](#auto-sast-for-docker-images) +1. [Auto Container Scanning](#auto-container-scanning) 1. [Auto Review Apps](#auto-review-apps) 1. [Auto DAST (Dynamic Application Security Testing)](#auto-dast) 1. [Auto Deploy](#auto-deploy) @@ -217,7 +217,7 @@ check out. In GitLab Ultimate, any security warnings are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html). -### Auto SAST for Docker images +### Auto Container Scanning > Introduced in GitLab 10.4. @@ -228,7 +228,7 @@ created, it's uploaded as an artifact which you can later download and check out. In GitLab Ultimate, any security warnings are also -[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast_docker.html). +[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/container_scanning.html). ### Auto Review Apps diff --git a/doc/update/10.5-to-10.6.md b/doc/update/10.5-to-10.6.md index af8343b5958..f5c5c305726 100644 --- a/doc/update/10.5-to-10.6.md +++ b/doc/update/10.5-to-10.6.md @@ -56,8 +56,8 @@ sudo gem install bundler --no-ri --no-rdoc ### 4. Update Node -GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets. -We require a minimum version of node v6.0.0. +GitLab utilizes [webpack](http://webpack.js.org) to compile frontend assets. +This requires a minimum version of node v6.0.0. You can check which version you are running with `node -v`. If you are running a version older than `v6.0.0` you will need to update to a newer version. You @@ -66,8 +66,8 @@ from source at the nodejs.org website. <https://nodejs.org/en/download/> -Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage -JavaScript dependencies. +GitLab also requires the use of yarn `>= v1.2.0` to manage JavaScript +dependencies. ```bash curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - diff --git a/doc/user/project/repository/img/jupyter_notebook.png b/doc/user/project/repository/img/jupyter_notebook.png Binary files differnew file mode 100644 index 00000000000..52c5c5aea32 --- /dev/null +++ b/doc/user/project/repository/img/jupyter_notebook.png diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index e6aede7f46e..ae131d51305 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -53,6 +53,22 @@ To get started with the command line, please read through the Use GitLab's [file finder](../../../workflow/file_finder.md) to search for files in a repository. +### Jupyter Notebook files + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/2508) in GitLab 9.1 + +[Jupyter][jupyter] Notebook (previously IPython Notebook) files are used for +interactive computing in many fields and contain a complete record of the +user's sessions and include code, narrative text, equations and rich output. + +When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be +rendered to HTML when viewed. + +![Jupyter Notebook Rich Output](img/jupyter_notebook.png) + +Interactive features, including JavaScript plots, will not work when viewed in +GitLab. + ## Branches When you submit changes in a new [branch](branches/index.md), you create a new version @@ -158,3 +174,5 @@ Lock your files to prevent any conflicting changes. ## Repository's API You can access your repos via [repository API](../../../api/repositories.md). + +[jupyter]: https://jupyter.org diff --git a/features/project/graph.feature b/features/project/graph.feature deleted file mode 100644 index b25c73ad870..00000000000 --- a/features/project/graph.feature +++ /dev/null @@ -1,33 +0,0 @@ -Feature: Project Graph - Background: - Given I sign in as a user - And I own project "Shop" - - @javascript - Scenario: I should see project graphs - When I visit project "Shop" graph page - Then page should have graphs - - @javascript - Scenario: I should see project languages & commits graphs on commits graph url - When I visit project "Shop" commits graph page - Then page should have commits graphs - Then page should have languages graphs - - @javascript - Scenario: I should see project ci graphs - Given project "Shop" has CI enabled - When I visit project "Shop" CI graph page - Then page should have CI graphs - - @javascript - Scenario: I should see project languages & commits graphs on language graph url - When I visit project "Shop" languages graph page - Then page should have languages graphs - Then page should have commits graphs - - @javascript - Scenario: I should see project languages & commits graphs on charts url - When I visit project "Shop" chart page - Then page should have languages graphs - Then page should have commits graphs diff --git a/features/project/redirects.feature b/features/project/redirects.feature deleted file mode 100644 index a2e77e7bf30..00000000000 --- a/features/project/redirects.feature +++ /dev/null @@ -1,38 +0,0 @@ -Feature: Project Redirects - Background: - Given public project "Community" - And private project "Enterprise" - - Scenario: I visit public project page - When I visit project "Community" page - Then I should see project "Community" home page - - Scenario: I visit private project page - When I visit project "Enterprise" page - Then I should be redirected to sign in page - - Scenario: I visit a non-existent project page - When I visit project "CommunityDoesNotExist" page - Then I should be redirected to sign in page - - Scenario: I visit a non-existent project page as user - Given I sign in as a user - When I visit project "CommunityDoesNotExist" page - Then page status code should be 404 - - Scenario: I visit unauthorized project page as user - Given I sign in as a user - When I visit project "Enterprise" page - Then page status code should be 404 - - Scenario: I visit a public project without signing in - When I visit project "Community" page - And I should see project "Community" home page - And I click on "Sign In" - And Authenticate - Then I should be redirected to "Community" page - - Scenario: I visit private project page without signing in - When I visit project "Enterprise" page - And I get redirected to signin page where I sign in - Then I should be redirected to "Enterprise" page diff --git a/features/steps/project/graph.rb b/features/steps/project/graph.rb deleted file mode 100644 index b9cddf4041d..00000000000 --- a/features/steps/project/graph.rb +++ /dev/null @@ -1,50 +0,0 @@ -class Spinach::Features::ProjectGraph < Spinach::FeatureSteps - include SharedAuthentication - include SharedProject - - step 'page should have graphs' do - expect(page).to have_selector ".stat-graph" - end - - When 'I visit project "Shop" graph page' do - visit project_graph_path(project, "master") - end - - step 'I visit project "Shop" commits graph page' do - visit commits_project_graph_path(project, "master") - end - - step 'I visit project "Shop" languages graph page' do - visit languages_project_graph_path(project, "master") - end - - step 'I visit project "Shop" chart page' do - visit charts_project_graph_path(project, "master") - end - - step 'page should have languages graphs' do - expect(page).to have_content /Ruby 66.* %/ - expect(page).to have_content /JavaScript 22.* %/ - end - - step 'page should have commits graphs' do - expect(page).to have_content "Commit statistics for master" - expect(page).to have_content "Commits per day of month" - end - - step 'I visit project "Shop" CI graph page' do - visit ci_project_graph_path(project, 'master') - end - - step 'page should have CI graphs' do - expect(page).to have_content 'Overall' - expect(page).to have_content 'Pipelines for last week' - expect(page).to have_content 'Pipelines for last month' - expect(page).to have_content 'Pipelines for last year' - expect(page).to have_content 'Commit duration in minutes for last 30 commits' - end - - def project - @project ||= Project.find_by(name: "Shop") - end -end diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb deleted file mode 100644 index 9ce86ca45d0..00000000000 --- a/features/steps/project/redirects.rb +++ /dev/null @@ -1,67 +0,0 @@ -class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedProject - - step 'public project "Community"' do - create(:project, :public, name: 'Community') - end - - step 'private project "Enterprise"' do - create(:project, :private, name: 'Enterprise') - end - - step 'I visit project "Community" page' do - project = Project.find_by(name: 'Community') - visit project_path(project) - end - - step 'I should see project "Community" home page' do - Gitlab.config.gitlab.should_receive(:host).and_return("www.example.com") - page.within '.breadcrumbs .breadcrumb-item-text' do - expect(page).to have_content 'Community' - end - end - - step 'I visit project "Enterprise" page' do - project = Project.find_by(name: 'Enterprise') - visit project_path(project) - end - - step 'I visit project "CommunityDoesNotExist" page' do - project = Project.find_by(name: 'Community') - visit project_path(project) + 'DoesNotExist' - end - - step 'I click on "Sign In"' do - first(:link, "Sign in").click - end - - step 'Authenticate' do - admin = create(:admin) - fill_in "user_login", with: admin.email - fill_in "user_password", with: admin.password - click_button "Sign in" - Thread.current[:current_user] = admin - end - - step 'I should be redirected to "Community" page' do - project = Project.find_by(name: 'Community') - expect(current_path).to eq "/#{project.full_path}" - expect(status_code).to eq 200 - end - - step 'I get redirected to signin page where I sign in' do - admin = create(:admin) - fill_in "user_login", with: admin.email - fill_in "user_password", with: admin.password - click_button "Sign in" - Thread.current[:current_user] = admin - end - - step 'I should be redirected to "Enterprise" page' do - project = Project.find_by(name: 'Enterprise') - expect(current_path).to eq "/#{project.full_path}" - expect(status_code).to eq 200 - end -end diff --git a/lib/api/project_export.rb b/lib/api/project_export.rb index 6ec2626df1a..b0a7fd6f4ab 100644 --- a/lib/api/project_export.rb +++ b/lib/api/project_export.rb @@ -31,8 +31,13 @@ module API desc 'Start export' do detail 'This feature was introduced in GitLab 10.6.' end + params do + optional :description, type: String, desc: 'Override the project description' + end post ':id/export' do - user_project.add_export_job(current_user: current_user) + project_export_params = declared_params(include_missing: false) + + user_project.add_export_job(current_user: current_user, params: project_export_params) accepted! end diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb index 9bdedeb6615..262458a872a 100644 --- a/lib/banzai/filter/relative_link_filter.rb +++ b/lib/banzai/filter/relative_link_filter.rb @@ -84,7 +84,7 @@ module Banzai relative_url_root, project.full_path, uri_type(file_path), - Addressable::URI.escape(ref), + Addressable::URI.escape(ref).gsub('#', '%23'), Addressable::URI.escape(file_path) ].compact.join('/').squeeze('/').chomp('/') diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index c7263f302ab..010ca1ec27b 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -52,6 +52,8 @@ module ContainerRegistry conn.request(:authorization, :bearer, options[:token].to_s) end + yield(conn) if block_given? + conn.adapter :net_http end @@ -80,8 +82,7 @@ module ContainerRegistry def faraday @faraday ||= Faraday.new(@base_uri) do |conn| - initialize_connection(conn, @options) - accept_manifest(conn) + initialize_connection(conn, @options, &method(:accept_manifest)) end end diff --git a/lib/gitlab/ci/variables/collection.rb b/lib/gitlab/ci/variables/collection.rb new file mode 100644 index 00000000000..0deca55fe8f --- /dev/null +++ b/lib/gitlab/ci/variables/collection.rb @@ -0,0 +1,38 @@ +module Gitlab + module Ci + module Variables + class Collection + include Enumerable + + def initialize(variables = []) + @variables = [] + + variables.each { |variable| self.append(variable) } + end + + def append(resource) + tap { @variables.append(Collection::Item.fabricate(resource)) } + end + + def concat(resources) + tap { resources.each { |variable| self.append(variable) } } + end + + def each + @variables.each { |variable| yield variable } + end + + def +(other) + self.class.new.tap do |collection| + self.each { |variable| collection.append(variable) } + other.each { |variable| collection.append(variable) } + end + end + + def to_runner_variables + self.map(&:to_hash) + end + end + end + end +end diff --git a/lib/gitlab/ci/variables/collection/item.rb b/lib/gitlab/ci/variables/collection/item.rb new file mode 100644 index 00000000000..939912981e6 --- /dev/null +++ b/lib/gitlab/ci/variables/collection/item.rb @@ -0,0 +1,50 @@ +module Gitlab + module Ci + module Variables + class Collection + class Item + def initialize(**options) + @variable = { + key: options.fetch(:key), + value: options.fetch(:value), + public: options.fetch(:public, true), + file: options.fetch(:files, false) + } + end + + def [](key) + @variable.fetch(key) + end + + def ==(other) + to_hash == self.class.fabricate(other).to_hash + end + + ## + # If `file: true` has been provided we expose it, otherwise we + # don't expose `file` attribute at all (stems from what the runner + # expects). + # + def to_hash + @variable.reject do |hash_key, hash_value| + hash_key == :file && hash_value == false + end + end + + def self.fabricate(resource) + case resource + when Hash + self.new(resource) + when ::HasVariable + self.new(resource.to_runner_variable) + when self + resource.dup + else + raise ArgumentError, "Unknown `#{resource.class}` variable resource!" + end + end + end + end + end + end +end diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index b7c596a973d..e392a015b91 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -70,7 +70,7 @@ module Gitlab active_db_connection = ActiveRecord::Base.connection.active? rescue false active_db_connection && - ActiveRecord::Base.connection.table_exists?('application_settings') + Gitlab::Database.cached_table_exists?('application_settings') rescue ActiveRecord::NoDatabaseError false end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index e51794fef99..76501dd50e8 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -183,6 +183,15 @@ module Gitlab ActiveRecord::Base.connection end + def self.cached_column_exists?(table_name, column_name) + connection.schema_cache.columns_hash(table_name).has_key?(column_name.to_s) + end + + def self.cached_table_exists?(table_name) + # Rails 5 uses data_source_exists? instead of table_exists? + connection.schema_cache.table_exists?(table_name) + end + private_class_method :connection def self.database_version diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index dbe6259fce7..21287a8efd0 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -859,6 +859,19 @@ into similar problems in the future (e.g. when new tables are created). BackgroundMigrationWorker.perform_in(delay_interval * index, job_class_name, [start_id, end_id]) 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. + def index_exists_by_name?(table, index) + indexes(table).map(&:name).include?(index) + end end end end diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb index ff68bc7303a..c358ae428cf 100644 --- a/lib/gitlab/diff/file_collection/merge_request_diff.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb @@ -29,6 +29,14 @@ module Gitlab @merge_request_diff.real_size end + def clear_cache! + Rails.cache.delete(cache_key) + end + + def cache_key + [@merge_request_diff, 'highlighted-diff-files', diff_options] + end + private def highlight_diff_file_from_cache!(diff_file, cache_diff_lines) @@ -64,16 +72,12 @@ module Gitlab end def store_highlight_cache - Rails.cache.write(cache_key, highlight_cache) if @highlight_cache_was_empty + Rails.cache.write(cache_key, highlight_cache, expires_in: 1.week) if @highlight_cache_was_empty end def cacheable?(diff_file) @merge_request_diff.present? && diff_file.text? && diff_file.diffable? end - - def cache_key - [@merge_request_diff, 'highlighted-diff-files', diff_options] - end end end end diff --git a/lib/gitlab/git/gitlab_projects.rb b/lib/gitlab/git/gitlab_projects.rb index 5e1e22ae65c..a142ed6b2ef 100644 --- a/lib/gitlab/git/gitlab_projects.rb +++ b/lib/gitlab/git/gitlab_projects.rb @@ -67,7 +67,7 @@ module Gitlab tags_option = tags ? '--tags' : '--no-tags' logger.info "Fetching remote #{name} for repository #{repository_absolute_path}." - cmd = %W(git fetch #{name} --quiet) + cmd = %W(#{Gitlab.config.git.bin_path} fetch #{name} --quiet) cmd << '--prune' if prune cmd << '--force' if force cmd << tags_option @@ -85,7 +85,7 @@ module Gitlab def push_branches(remote_name, timeout, force, branch_names) logger.info "Pushing branches from #{repository_absolute_path} to remote #{remote_name}: #{branch_names}" - cmd = %w(git push) + cmd = %W(#{Gitlab.config.git.bin_path} push) cmd << '--force' if force cmd += %W(-- #{remote_name}).concat(branch_names) @@ -102,7 +102,7 @@ module Gitlab branches = branch_names.map { |branch_name| ":#{branch_name}" } logger.info "Pushing deleted branches from #{repository_absolute_path} to remote #{remote_name}: #{branch_names}" - cmd = %W(git push -- #{remote_name}).concat(branches) + cmd = %W(#{Gitlab.config.git.bin_path} push -- #{remote_name}).concat(branches) success = run(cmd, repository_absolute_path) @@ -143,7 +143,7 @@ module Gitlab end def remove_origin_in_repo - cmd = %w(git remote rm origin) + cmd = %W(#{Gitlab.config.git.bin_path} remote rm origin) run(cmd, repository_absolute_path) end @@ -223,7 +223,7 @@ module Gitlab masked_source = mask_password_in_url(source) logger.info "Importing project from <#{masked_source}> to <#{repository_absolute_path}>." - cmd = %W(git clone --bare -- #{source} #{repository_absolute_path}) + cmd = %W(#{Gitlab.config.git.bin_path} clone --bare -- #{source} #{repository_absolute_path}) success = run_with_timeout(cmd, timeout, nil) @@ -266,7 +266,7 @@ module Gitlab FileUtils.mkdir_p(File.dirname(to_path), mode: 0770) logger.info "Forking repository from <#{from_path}> to <#{to_path}>." - cmd = %W(git clone --bare --no-local -- #{from_path} #{to_path}) + cmd = %W(#{Gitlab.config.git.bin_path} clone --bare --no-local -- #{from_path} #{to_path}) run(cmd, nil) && Gitlab::Git::Repository.create_hooks(to_path, global_hooks_path) end diff --git a/lib/gitlab/git/lfs_pointer_file.rb b/lib/gitlab/git/lfs_pointer_file.rb index da12ed7d125..2ae0a889590 100644 --- a/lib/gitlab/git/lfs_pointer_file.rb +++ b/lib/gitlab/git/lfs_pointer_file.rb @@ -1,13 +1,16 @@ module Gitlab module Git class LfsPointerFile + VERSION = "https://git-lfs.github.com/spec/v1".freeze + VERSION_LINE = "version #{VERSION}".freeze + def initialize(data) @data = data end def pointer @pointer ||= <<~FILE - version https://git-lfs.github.com/spec/v1 + #{VERSION_LINE} oid sha256:#{sha256} size #{size} FILE @@ -20,6 +23,10 @@ module Gitlab def sha256 @sha256 ||= Digest::SHA256.hexdigest(@data) end + + def inspect + "#<#{self.class}:#{object_id} @size=#{size}, @sha256=#{sha256.inspect}>" + end end end end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index fbc93542619..9811c447a01 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1002,8 +1002,9 @@ module Gitlab # This only checks the root .gitattributes file, # it does not traverse subfolders to find additional .gitattributes files # - # This method is around 30 times slower than `attributes`, - # which uses `$GIT_DIR/info/attributes` + # This method is around 30 times slower than `attributes`, which uses + # `$GIT_DIR/info/attributes`. Consider caching AttributesAtRefParser + # and reusing that for multiple calls instead of this method. def attributes_at(ref, file_path) parser = AttributesAtRefParser.new(self, ref) parser.attributes(file_path) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 9cd76630484..8ca30ffc232 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -119,6 +119,9 @@ module Gitlab # def self.call(storage, service, rpc, request, remote_storage: nil, timeout: nil) start = Gitlab::Metrics::System.monotonic_time + request_hash = request.is_a?(Google::Protobuf::MessageExts) ? request.to_h : {} + @current_call_id ||= SecureRandom.uuid + enforce_gitaly_request_limits(:call) kwargs = request_kwargs(storage, timeout, remote_storage: remote_storage) @@ -135,6 +138,10 @@ module Gitlab gitaly_controller_action_duration_seconds.observe( current_transaction_labels.merge(gitaly_service: service.to_s, rpc: rpc.to_s), duration) + + add_call_details(id: @current_call_id, feature: service, duration: duration, request: request_hash) + + @current_call_id = nil end def self.handle_grpc_unavailable!(ex) @@ -252,12 +259,16 @@ module Gitlab feature_stack.unshift(feature) begin start = Gitlab::Metrics::System.monotonic_time + @current_call_id = SecureRandom.uuid + call_details = { id: @current_call_id } yield is_enabled ensure total_time = Gitlab::Metrics::System.monotonic_time - start gitaly_migrate_call_duration_seconds.observe({ gitaly_enabled: is_enabled, feature: feature }, total_time) feature_stack.shift Thread.current[:gitaly_feature_stack] = nil if feature_stack.empty? + + add_call_details(call_details.merge(feature: feature, duration: total_time)) end end end @@ -344,6 +355,22 @@ module Gitlab end end + def self.add_call_details(details) + id = details.delete(:id) + + return unless id && RequestStore.active? && RequestStore.store[:peek_enabled] + + RequestStore.store['gitaly_call_details'] ||= {} + RequestStore.store['gitaly_call_details'][id] ||= {} + RequestStore.store['gitaly_call_details'][id].merge!(details) + end + + def self.list_call_details + return {} unless RequestStore.active? && RequestStore.store[:peek_enabled] + + RequestStore.store['gitaly_call_details'] || {} + end + def self.expected_server_version path = Rails.root.join(SERVER_VERSION_FILE) path.read.chomp diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index 4b5f9f3a926..8f5bb8f9597 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -35,6 +35,8 @@ module Gitlab end def restored_project + return @project unless @tree_hash + @restored_project ||= restore_project end @@ -81,9 +83,13 @@ module Gitlab end def restore_project - return @project unless @tree_hash + params = project_params + + if params[:description].present? + params[:description_html] = nil + end - @project.update_columns(project_params) + @project.update_columns(params) @project end diff --git a/lib/gitlab/import_export/project_tree_saver.rb b/lib/gitlab/import_export/project_tree_saver.rb index 3473b466936..5510c0b8b2f 100644 --- a/lib/gitlab/import_export/project_tree_saver.rb +++ b/lib/gitlab/import_export/project_tree_saver.rb @@ -5,7 +5,8 @@ module Gitlab attr_reader :full_path - def initialize(project:, current_user:, shared:) + def initialize(project:, current_user:, shared:, params: {}) + @params = params @project = project @current_user = current_user @shared = shared @@ -25,6 +26,10 @@ module Gitlab private def project_json_tree + if @params[:description].present? + project_json['description'] = @params[:description] + end + project_json['project_members'] += group_members_json project_json.to_json diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index cf6b7e306dd..791a54e1b69 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -70,6 +70,7 @@ module Gitlab update_user_references update_project_references + remove_duplicate_assignees reset_tokens! remove_encrypted_attributes! @@ -83,6 +84,14 @@ module Gitlab end end + def remove_duplicate_assignees + return unless @relation_hash['issue_assignees'] + + # When an assignee did not exist in the members mapper, the importer is + # assigned. We only need to assign each user once. + @relation_hash['issue_assignees'].uniq!(&:user_id) + end + def setup_note set_note_author # attachment is deprecated and note uploads are handled by Markdown uploader diff --git a/lib/gitlab/kubernetes/namespace.rb b/lib/gitlab/kubernetes/namespace.rb index fbbddb7bffa..e6ff6160ab9 100644 --- a/lib/gitlab/kubernetes/namespace.rb +++ b/lib/gitlab/kubernetes/namespace.rb @@ -10,7 +10,7 @@ module Gitlab def exists? @client.get_namespace(name) - rescue ::KubeException => ke + rescue ::Kubeclient::HttpError => ke raise ke unless ke.error_code == 404 false diff --git a/lib/gitlab/metrics/methods.rb b/lib/gitlab/metrics/methods.rb index cd7c1e507f7..f79eb0cd1bf 100644 --- a/lib/gitlab/metrics/methods.rb +++ b/lib/gitlab/metrics/methods.rb @@ -50,7 +50,7 @@ module Gitlab end def disabled_by_feature(options) - options.with_feature && !Feature.get(options.with_feature).enabled? + options.with_feature && !::Feature.get(options.with_feature).enabled? end def build_metric!(type, name, options) diff --git a/lib/gitlab/project_transfer.rb b/lib/gitlab/project_transfer.rb index 1bba0b78e2f..690c38737c0 100644 --- a/lib/gitlab/project_transfer.rb +++ b/lib/gitlab/project_transfer.rb @@ -1,13 +1,19 @@ module Gitlab + # This class is used to move local, unhashed files owned by projects to their new location class ProjectTransfer - def move_project(project_path, namespace_path_was, namespace_path) - new_namespace_folder = File.join(root_dir, namespace_path) - FileUtils.mkdir_p(new_namespace_folder) unless Dir.exist?(new_namespace_folder) - from = File.join(root_dir, namespace_path_was, project_path) - to = File.join(root_dir, namespace_path, project_path) + # nil parent_path (or parent_path_was) represents a root namespace + def move_namespace(path, parent_path_was, parent_path) + parent_path_was ||= '' + parent_path ||= '' + new_parent_folder = File.join(root_dir, parent_path) + FileUtils.mkdir_p(new_parent_folder) + from = File.join(root_dir, parent_path_was, path) + to = File.join(root_dir, parent_path, path) move(from, to, "") end + alias_method :move_project, :move_namespace + def rename_project(path_was, path, namespace_path) base_dir = File.join(root_dir, namespace_path) move(path_was, path, base_dir) diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index dda7afc0999..3a8f5826818 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -69,13 +69,14 @@ module Gitlab # name - project disk path # # Ex. - # add_repository("/path/to/storage", "gitlab/gitlab-ci") + # create_repository("/path/to/storage", "gitlab/gitlab-ci") # - def add_repository(storage, name) + def create_repository(storage, name) relative_path = name.dup relative_path << '.git' unless relative_path.end_with?('.git') - gitaly_migrate(:create_repository) do |is_enabled| + gitaly_migrate(:create_repository, + status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled| if is_enabled repository = Gitlab::Git::Repository.new(storage, relative_path, '') repository.gitaly_repository_client.create_repository @@ -85,7 +86,7 @@ module Gitlab Gitlab::Git::Repository.create(repo_path, bare: true, symlink_hooks_to: gitlab_shell_hooks_path) end end - rescue => err + rescue => err # Once the Rugged codes gets removes this can be improved Rails.logger.error("Failed to add repository #{storage}/#{name}: #{err}") false end @@ -487,8 +488,8 @@ module Gitlab Gitlab.config.gitlab_shell.git_timeout end - def gitaly_migrate(method, &block) - Gitlab::GitalyClient.migrate(method, &block) + def gitaly_migrate(method, status: Gitlab::GitalyClient::MigrationStatus::OPT_IN, &block) + Gitlab::GitalyClient.migrate(method, status: status, &block) rescue GRPC::NotFound, GRPC::BadStatus => e # Old Popen code returns [Error, output] to the caller, so we # need to do the same here... diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb index 85aaa6b0eba..bb778f37096 100644 --- a/lib/gitlab/slash_commands/command.rb +++ b/lib/gitlab/slash_commands/command.rb @@ -5,6 +5,7 @@ module Gitlab Gitlab::SlashCommands::IssueShow, Gitlab::SlashCommands::IssueNew, Gitlab::SlashCommands::IssueSearch, + Gitlab::SlashCommands::IssueMove, Gitlab::SlashCommands::Deploy ].freeze diff --git a/lib/gitlab/slash_commands/issue_move.rb b/lib/gitlab/slash_commands/issue_move.rb new file mode 100644 index 00000000000..3985e635983 --- /dev/null +++ b/lib/gitlab/slash_commands/issue_move.rb @@ -0,0 +1,45 @@ +module Gitlab + module SlashCommands + class IssueMove < IssueCommand + def self.match(text) + %r{ + \A # the beginning of a string + issue\s+move\s+ # the command + \#?(?<iid>\d+)\s+ # the issue id, may preceded by hash sign + (to\s+)? # aid the command to be much more human-ly + (?<project_path>[^\s]+) # named group for id of dest. project + }x.match(text) + end + + def self.help_message + 'issue move <issue_id> (to)? <project_path>' + end + + def self.allowed?(project, user) + can?(user, :admin_issue, project) + end + + def execute(match) + old_issue = find_by_iid(match[:iid]) + target_project = Project.find_by_full_path(match[:project_path]) + + unless current_user.can?(:read_project, target_project) && old_issue + return Gitlab::SlashCommands::Presenters::Access.new.not_found + end + + new_issue = Issues::MoveService.new(project, current_user) + .execute(old_issue, target_project) + + presenter(new_issue).present(old_issue) + rescue Issues::MoveService::MoveError => e + presenter(old_issue).display_move_error(e.message) + end + + private + + def presenter(issue) + Gitlab::SlashCommands::Presenters::IssueMove.new(issue) + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/issue_move.rb b/lib/gitlab/slash_commands/presenters/issue_move.rb new file mode 100644 index 00000000000..03921729941 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/issue_move.rb @@ -0,0 +1,53 @@ +# coding: utf-8 +module Gitlab + module SlashCommands + module Presenters + class IssueMove < Presenters::Base + include Presenters::IssueBase + + def present(old_issue) + in_channel_response(moved_issue(old_issue)) + end + + def display_move_error(error) + message = header_with_list("The action was not successful, because:", [error]) + + ephemeral_response(text: message) + end + + private + + def moved_issue(old_issue) + { + attachments: [ + { + title: "#{@resource.title} · #{@resource.to_reference}", + title_link: resource_url, + author_name: author.name, + author_icon: author.avatar_url, + fallback: "Issue #{@resource.to_reference}: #{@resource.title}", + pretext: pretext(old_issue), + color: color(@resource), + fields: fields, + mrkdwn_in: [ + :title, + :pretext, + :text, + :fields + ] + } + ] + } + end + + def pretext(old_issue) + "Moved issue *#{issue_link(old_issue)}* to *#{issue_link(@resource)}*" + end + + def issue_link(issue) + "[#{issue.to_reference}](#{project_issue_url(issue.project, issue)})" + end + end + end + end +end diff --git a/lib/peek/views/gitaly.rb b/lib/peek/views/gitaly.rb index d519d8e86fa..ab35f7a2258 100644 --- a/lib/peek/views/gitaly.rb +++ b/lib/peek/views/gitaly.rb @@ -10,11 +10,29 @@ module Peek end def results - { duration: formatted_duration, calls: calls } + { + duration: formatted_duration, + calls: calls, + details: details + } end private + def details + ::Gitlab::GitalyClient.list_call_details + .values + .sort { |a, b| b[:duration] <=> a[:duration] } + .map(&method(:format_call_details)) + end + + def format_call_details(call) + pretty_request = call[:request]&.reject { |k, v| v.blank? }.to_h.pretty_inspect + + call.merge(duration: (call[:duration] * 1000).round(3), + request: pretty_request || {}) + end + def formatted_duration ms = duration * 1000 if ms >= 1000 diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index 844664b12d4..4fcbbbf8c9d 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -69,7 +69,7 @@ namespace :gitlab do if File.exist?(path_to_repo) print '-' else - if Gitlab::Shell.new.add_repository(project.repository_storage, + if Gitlab::Shell.new.create_repository(project.repository_storage, project.disk_path) print '.' else diff --git a/package.json b/package.json index 472bdbebda8..deee668ae3b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "karma": "karma start config/karma.config.js --single-run", "karma-coverage": "BABEL_ENV=coverage karma start config/karma.config.js --single-run", "karma-start": "karma start config/karma.config.js", + "prettier-staged": "node ./scripts/frontend/prettier.js", + "prettier-staged-save": "node ./scripts/frontend/prettier.js save", + "prettier-all": "node ./scripts/frontend/prettier.js check-all", + "prettier-all-save": "node ./scripts/frontend/prettier.js save-all", "webpack": "webpack --config config/webpack.config.js", "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, @@ -114,7 +118,7 @@ "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "2.0.7", "nodemon": "^1.15.1", - "prettier": "1.9.2", + "prettier": "1.11.1", "webpack-dev-server": "^2.11.2" } } 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_. diff --git a/scripts/frontend/frontend_script_utils.js b/scripts/frontend/frontend_script_utils.js new file mode 100644 index 00000000000..2c06747255c --- /dev/null +++ b/scripts/frontend/frontend_script_utils.js @@ -0,0 +1,30 @@ +/* eslint import/no-commonjs: "off" */ +const execFileSync = require('child_process').execFileSync; + +const exec = (command, args) => { + const options = { + cwd: process.cwd(), + env: process.env, + encoding: 'utf-8', + }; + return execFileSync(command, args, options); +}; + +const execGitCmd = args => + exec('git', args) + .trim() + .toString() + .split('\n'); + +module.exports = { + getStagedFiles: fileExtensionFilter => { + 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 new file mode 100644 index 00000000000..863572bf64d --- /dev/null +++ b/scripts/frontend/prettier.js @@ -0,0 +1,103 @@ +/* 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'); + +const getStagedFiles = require('./frontend_script_utils').getStagedFiles; + +const mode = process.argv[2] || 'check'; +const shouldSave = mode === 'save' || mode === 'save-all'; +const allFiles = mode === 'check-all' || mode === 'save-all'; + +const config = { + patterns: ['**/*.js', '**/*.vue', '**/*.scss'], + ignore: ['**/node_modules/**', '**/vendor/**', '**/public/**'], + parsers: { + js: 'babylon', + vue: 'vue', + scss: 'css', + }, +}; +const availableExtensions = Object.keys(config.parsers); + +console.log(`Loading ${allFiles ? 'All' : 'Staged'} Files ...`); + +const stagedFiles = allFiles + ? null + : getStagedFiles(availableExtensions.map(ext => `*.${ext}`)); + +if (stagedFiles) { + if (!stagedFiles.length || (stagedFiles.length === 1 && !stagedFiles[0])) { + console.log('No matching staged files.'); + return; + } + console.log(`Matching staged Files : ${stagedFiles.length}`); +} + +let didWarn = false; +let didError = false; + +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)); +} else { + files = stagedFiles.filter(f => + availableExtensions.includes(f.split('.').pop()), + ); +} + +if (!files.length) { + console.log('No Files found to process with Prettier'); + return; +} + +console.log(`${shouldSave ? 'Updating' : 'Checking'} ${files.length} file(s)`); + +prettier + .resolveConfig('.') + .then(options => { + console.log('Found options : ', options); + files.forEach(file => { + try { + const fileExtension = file.split('.').pop(); + Object.assign(options, { + parser: config.parsers[fileExtension], + }); + + const input = fs.readFileSync(file, 'utf8'); + + if (shouldSave) { + const output = prettier.format(input, options); + if (output !== input) { + fs.writeFileSync(file, output, 'utf8'); + console.log(`Prettified : ${file}`); + } + } 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', + ); + didWarn = true; + } + console.log(`Prettify Manually : ${file}`); + } + } catch (error) { + didError = true; + console.log(`\n\nError with ${file}: ${error.message}`); + } + }); + + if (didWarn || didError) { + process.exit(1); + } + }) + .catch(e => { + console.log(`Error on loading the Config File: ${e.message}`); + process.exit(1); + }); diff --git a/spec/features/ci_lint_spec.rb b/spec/features/ci_lint_spec.rb index b1dceec9da8..220b934154e 100644 --- a/spec/features/ci_lint_spec.rb +++ b/spec/features/ci_lint_spec.rb @@ -39,6 +39,7 @@ describe 'CI Lint', :js do it 'displays information about an error' do expect(page).to have_content('Status: syntax is incorrect') + expect(page).to have_selector('.ace_content', text: yaml_content) end end diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index 3e83a549682..b4ad4b64d8e 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -108,6 +108,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do it 'shows resolved discussion when toggled' do find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click + expect(page.find(".line-holder-placeholder")).to be_visible expect(page.find(".timeline-content #note_#{note.id}")).to be_visible end end diff --git a/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb b/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb index 8a834adbf17..565e375600b 100644 --- a/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb +++ b/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb @@ -5,15 +5,18 @@ describe 'Merge request > User scrolls to note on load', :js do let(:user) { project.creator } let(:merge_request) { create(:merge_request, source_project: project, author: user) } let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:resolved_note) { create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: project) } let(:fragment_id) { "#note_#{note.id}" } + let(:collapsed_fragment_id) { "#note_#{resolved_note.id}" } before do sign_in(user) page.current_window.resize_to(1000, 300) - visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}" end - it 'scrolls down to fragment' do + it 'scrolls note into view' do + visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}" + page_height = page.current_window.size[1] page_scroll_y = page.evaluate_script("window.scrollY") fragment_position_top = page.evaluate_script("Math.round($('#{fragment_id}').offset().top)") @@ -23,4 +26,13 @@ describe 'Merge request > User scrolls to note on load', :js do expect(fragment_position_top).to be >= page_scroll_y expect(fragment_position_top).to be < (page_scroll_y + page_height) end + + it 'expands collapsed notes' do + visit "#{project_merge_request_path(project, merge_request)}#{collapsed_fragment_id}" + note_element = find(collapsed_fragment_id) + note_container = note_element.ancestor('.js-toggle-container') + + expect(note_element.visible?).to eq true + expect(note_container.find('.line_content.noteable_line.old', match: :first).visible?).to eq true + end end diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index 3abe363d523..f744d7941f5 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -22,7 +22,7 @@ describe 'Merge request > User sees deployment widget', :js do wait_for_requests expect(page).to have_content("Deployed to #{environment.name}") - expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) + expect(find('.js-deploy-time')['data-original-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) end context 'with stop action' do diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb index 1952fdae798..95953fbcfac 100644 --- a/spec/features/profiles/user_visits_notifications_tab_spec.rb +++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb @@ -16,6 +16,6 @@ feature 'User visits the notifications tab', :js do first('#notifications-button').click click_link('On mention') - expect(page).to have_content('On mention') + expect(page).to have_selector('#notifications-button', text: 'On mention') end end diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 88813d9b5ff..ac82f869f0f 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -509,4 +509,29 @@ feature 'File blob', :js do end end end + + context 'realtime pipelines' do + before do + Files::CreateService.new( + project, + project.creator, + start_branch: 'feature', + branch_name: 'feature', + commit_message: "Add ruby file", + file_path: 'files/ruby/test.rb', + file_content: "# Awesome content" + ).execute + + create(:ci_pipeline, status: 'running', project: project, ref: 'feature', sha: project.commit('feature').sha) + visit_blob('files/ruby/test.rb', ref: 'feature') + end + + it 'should show the realtime pipeline status' do + page.within('.commit-actions') do + expect(page).to have_css('.ci-status-icon') + expect(page).to have_css('.ci-status-icon-running') + expect(page).to have_css('.js-ci-status-icon-running') + end + end + end end diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb new file mode 100644 index 00000000000..57172610aed --- /dev/null +++ b/spec/features/projects/graph_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe 'Project Graph', :js do + let(:user) { create :user } + let(:project) { create(:project, :repository, namespace: user.namespace) } + + before do + project.add_master(user) + + sign_in(user) + end + + shared_examples 'page should have commits graphs' do + it 'renders commits' do + expect(page).to have_content('Commit statistics for master') + expect(page).to have_content('Commits per day of month') + end + end + + shared_examples 'page should have languages graphs' do + it 'renders languages' do + expect(page).to have_content(/Ruby 66.* %/) + expect(page).to have_content(/JavaScript 22.* %/) + end + end + + it 'renders graphs' do + visit project_graph_path(project, 'master') + + expect(page).to have_selector('.stat-graph', visible: false) + end + + context 'commits graph' do + before do + visit commits_project_graph_path(project, 'master') + end + + it_behaves_like 'page should have commits graphs' + it_behaves_like 'page should have languages graphs' + end + + context 'languages graph' do + before do + visit languages_project_graph_path(project, 'master') + end + + it_behaves_like 'page should have commits graphs' + it_behaves_like 'page should have languages graphs' + end + + context 'charts graph' do + before do + visit charts_project_graph_path(project, 'master') + end + + it_behaves_like 'page should have commits graphs' + it_behaves_like 'page should have languages graphs' + end + + context 'when CI enabled' do + before do + project.enable_ci + + visit ci_project_graph_path(project, 'master') + end + + it 'renders CI graphs' do + expect(page).to have_content 'Overall' + expect(page).to have_content 'Pipelines for last week' + expect(page).to have_content 'Pipelines for last month' + expect(page).to have_content 'Pipelines for last year' + expect(page).to have_content 'Commit duration in minutes for last 30 commits' + end + end +end diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index e8bb9c6a86c..b25f5161748 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -41,6 +41,7 @@ feature 'Import/Export - project import integration test', :js do project = Project.last expect(project).not_to be_nil + expect(project.description).to eq("Foo Bar") expect(project.issues).not_to be_empty expect(project.merge_requests).not_to be_empty expect(project_hook_exists?(project)).to be true diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz Binary files differindex 0cc68aff494..ecb7651acad 100644 --- a/spec/features/projects/import_export/test_project_export.tar.gz +++ b/spec/features/projects/import_export/test_project_export.tar.gz diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 33ad59abfdf..0e81c6c629a 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -349,6 +349,18 @@ describe 'Pipelines', :js do it { expect(page).not_to have_selector('.build-artifacts') } end + + context 'with trace artifact' do + before do + create(:ci_build, :success, :trace_artifact, pipeline: pipeline) + + visit_project_pipelines + end + + it 'does not show trace artifact as artifacts' do + expect(page).not_to have_selector('.build-artifacts') + end + end end context 'mini pipeline graph' do diff --git a/spec/features/projects/redirects_spec.rb b/spec/features/projects/redirects_spec.rb new file mode 100644 index 00000000000..d1d8ca07035 --- /dev/null +++ b/spec/features/projects/redirects_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe 'Project redirects' do + let(:user) { create :user } + let(:public_project) { create :project, :public } + let(:private_project) { create :project, :private } + + before do + allow(Gitlab.config.gitlab).to receive(:host).and_return('www.example.com') + end + + it 'shows public project page' do + visit project_path(public_project) + + page.within '.breadcrumbs .breadcrumb-item-text' do + expect(page).to have_content(public_project.name) + end + end + + it 'redirects to sign in page when project is private' do + visit project_path(private_project) + + expect(current_path).to eq(new_user_session_path) + end + + it 'redirects to sign in page when project does not exist' do + visit project_path(build(:project, :public)) + + expect(current_path).to eq(new_user_session_path) + end + + it 'redirects to public project page after signing in' do + visit project_path(public_project) + + first(:link, 'Sign in').click + + fill_in 'user_login', with: user.email + fill_in 'user_password', with: user.password + click_button 'Sign in' + + expect(status_code).to eq(200) + expect(current_path).to eq("/#{public_project.full_path}") + end + + it 'redirects to private project page after sign in' do + visit project_path(private_project) + + owner = private_project.owner + fill_in 'user_login', with: owner.email + fill_in 'user_password', with: owner.password + click_button 'Sign in' + + expect(status_code).to eq(200) + expect(current_path).to eq("/#{private_project.full_path}") + end + + context 'when signed in' do + before do + sign_in(user) + end + + it 'returns 404 status when project does not exist' do + visit project_path(build(:project, :public)) + + expect(status_code).to eq(404) + end + + it 'returns 404 when project is private' do + visit project_path(private_project) + + expect(status_code).to eq(404) + end + end +end diff --git a/spec/features/projects/show_project_spec.rb b/spec/features/projects/show_project_spec.rb index 0a014e9f080..e4f13e6cab7 100644 --- a/spec/features/projects/show_project_spec.rb +++ b/spec/features/projects/show_project_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Project show page', :feature do + include DropzoneHelper + context 'when project pending delete' do let(:project) { create(:project, :empty_repo, pending_delete: true) } @@ -334,4 +336,24 @@ describe 'Project show page', :feature do end end end + + describe 'dropzone', :js do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + + visit project_path(project) + end + + it 'can upload files' do + find('.add-to-tree').click + click_link 'Upload file' + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + + expect(find('.dz-filename')).to have_content('doc_sample.txt') + end + end end diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb index 57d843c1be2..033155617c6 100644 --- a/spec/helpers/import_helper_spec.rb +++ b/spec/helpers/import_helper_spec.rb @@ -28,12 +28,10 @@ describe ImportHelper do describe '#provider_project_link' do context 'when provider is "github"' do let(:github_server_url) { nil } + let(:provider) { OpenStruct.new(name: 'github', url: github_server_url) } before do - setting = Settingslogic.new('name' => 'github') - setting['url'] = github_server_url if github_server_url - - allow(Gitlab.config.omniauth).to receive(:providers).and_return([setting]) + stub_omniauth_setting(providers: [provider]) end context 'when provider does not specify a custom URL' do diff --git a/spec/javascripts/notes/components/comment_form_spec.js b/spec/javascripts/notes/components/comment_form_spec.js index 90016436cb7..224debbeff6 100644 --- a/spec/javascripts/notes/components/comment_form_spec.js +++ b/spec/javascripts/notes/components/comment_form_spec.js @@ -200,6 +200,20 @@ describe('issue_comment_form component', () => { done(); }); }); + + describe('when clicking close/reopen button', () => { + it('should disable button and show a loading spinner', (done) => { + const toggleStateButton = vm.$el.querySelector('.js-action-button'); + + toggleStateButton.click(); + Vue.nextTick(() => { + expect(toggleStateButton.disabled).toEqual(true); + expect(toggleStateButton.querySelector('.js-loading-button-icon')).not.toBeNull(); + + done(); + }); + }); + }); }); describe('issue is confidential', () => { diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index b838cc36fb3..91249b2c79e 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -88,6 +88,7 @@ describe('Actions Notes Store', () => { store.dispatch('closeIssue', { notesData: { closeIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('closed'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); done(); }) .catch(done.fail); @@ -99,6 +100,7 @@ describe('Actions Notes Store', () => { store.dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('reopened'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); done(); }) .catch(done.fail); @@ -117,6 +119,20 @@ describe('Actions Notes Store', () => { }); }); + describe('toggleStateButtonLoading', () => { + it('should set loading as true', (done) => { + testAction(actions.toggleStateButtonLoading, true, {}, [ + { type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }, + ], done); + }); + + it('should set loading as false', (done) => { + testAction(actions.toggleStateButtonLoading, false, {}, [ + { type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }, + ], done); + }); + }); + describe('toggleIssueLocalState', () => { it('sets issue state as closed', (done) => { testAction(actions.toggleIssueLocalState, 'closed', {}, [ diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index 34884f8968f..98f101d6bc5 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -228,4 +228,70 @@ describe('Notes Store mutations', () => { expect(state.notes[0].notes[0].note).toEqual('Foo'); }); }); + + describe('CLOSE_ISSUE', () => { + it('should set issue as closed', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.CLOSE_ISSUE(state); + expect(state.noteableData.state).toEqual('closed'); + }); + }); + + describe('REOPEN_ISSUE', () => { + it('should set issue as closed', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.REOPEN_ISSUE(state); + expect(state.noteableData.state).toEqual('reopened'); + }); + }); + + describe('TOGGLE_STATE_BUTTON_LOADING', () => { + it('should set isToggleStateButtonLoading as true', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_STATE_BUTTON_LOADING(state, true); + expect(state.isToggleStateButtonLoading).toEqual(true); + }); + + it('should set isToggleStateButtonLoading as false', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: true, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_STATE_BUTTON_LOADING(state, false); + expect(state.isToggleStateButtonLoading).toEqual(false); + }); + }); }); diff --git a/spec/javascripts/vue_mr_widget/components/deployment_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_spec.js new file mode 100644 index 00000000000..ff8d54c029f --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/deployment_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import * as urlUtils from '~/lib/utils/url_utility'; +import deploymentComponent from '~/vue_merge_request_widget/components/deployment.vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import { getTimeago } from '~/lib/utils/datetime_utility'; + +const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', +}; +const createComponent = () => { + const Component = Vue.extend(deploymentComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { deployment: { ...deploymentMockData } }, + }); +}; + +describe('Deployment component', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('deployTimeago', () => { + it('return formatted date', () => { + const readable = getTimeago().format(deploymentMockData.deployed_at); + expect(vm.deployTimeago).toEqual(readable); + }); + }); + + describe('hasExternalUrls', () => { + it('should return true', () => { + expect(vm.hasExternalUrls).toEqual(true); + }); + + it('should return false when deployment has no external_url_formatted', () => { + vm.deployment.external_url_formatted = null; + + expect(vm.hasExternalUrls).toEqual(false); + }); + + it('should return false when deployment has no external_url', () => { + vm.deployment.external_url = null; + + expect(vm.hasExternalUrls).toEqual(false); + }); + }); + + describe('hasDeploymentTime', () => { + it('should return true', () => { + expect(vm.hasDeploymentTime).toEqual(true); + }); + + it('should return false when deployment has no deployed_at', () => { + vm.deployment.deployed_at = null; + + expect(vm.hasDeploymentTime).toEqual(false); + }); + + it('should return false when deployment has no deployed_at_formatted', () => { + vm.deployment.deployed_at_formatted = null; + + expect(vm.hasDeploymentTime).toEqual(false); + }); + }); + + describe('hasDeploymentMeta', () => { + it('should return true', () => { + expect(vm.hasDeploymentMeta).toEqual(true); + }); + + it('should return false when deployment has no url', () => { + vm.deployment.url = null; + + expect(vm.hasDeploymentMeta).toEqual(false); + }); + + it('should return false when deployment has no name', () => { + vm.deployment.name = null; + + expect(vm.hasDeploymentMeta).toEqual(false); + }); + }); + }); + + describe('methods', () => { + describe('stopEnvironment', () => { + const url = '/foo/bar'; + const returnPromise = () => new Promise((resolve) => { + resolve({ + data: { + redirect_url: url, + }, + }); + }); + const mockStopEnvironment = () => { + vm.stopEnvironment(deploymentMockData); + return vm; + }; + + it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); + spyOn(urlUtils, 'visitUrl').and.returnValue(true); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); + setTimeout(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith(url); + done(); + }, 333); + }); + + it('should show a confirm dialog but should not work if the dialog is rejected', () => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + let el; + + beforeEach(() => { + vm = createComponent(deploymentMockData); + el = vm.$el; + }); + + it('renders deployment name', () => { + expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deploymentMockData.url); + expect(el.querySelector('.js-deploy-meta').innerText).toContain(deploymentMockData.name); + }); + + it('renders external URL', () => { + expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deploymentMockData.external_url); + expect(el.querySelector('.js-deploy-url').innerText).toContain(deploymentMockData.external_url_formatted); + }); + + it('renders stop button', () => { + expect(el.querySelector('.btn')).not.toBeNull(); + }); + + it('renders deployment time', () => { + expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.deployTimeago); + }); + + it('renders metrics component', () => { + expect(el.querySelector('.js-mr-memory-usage')).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js deleted file mode 100644 index 6a59dc3c87e..00000000000 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js +++ /dev/null @@ -1,179 +0,0 @@ -import Vue from 'vue'; -import * as urlUtils from '~/lib/utils/url_utility'; -import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment'; -import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; -import { getTimeago } from '~/lib/utils/datetime_utility'; - -const deploymentMockData = [ - { - id: 15, - name: 'review/diplo', - url: '/root/acets-review-apps/environments/15', - stop_url: '/root/acets-review-apps/environments/15/stop', - metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', - metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', - external_url: 'http://diplo.', - external_url_formatted: 'diplo.', - deployed_at: '2017-03-22T22:44:42.258Z', - deployed_at_formatted: 'Mar 22, 2017 10:44pm', - }, -]; -const createComponent = () => { - const Component = Vue.extend(deploymentComponent); - const mr = { - deployments: deploymentMockData, - }; - const service = {}; - - return new Component({ - el: document.createElement('div'), - propsData: { mr, service }, - }); -}; - -describe('MRWidgetDeployment', () => { - describe('props', () => { - it('should have props', () => { - const { mr, service } = deploymentComponent.props; - - expect(mr.type instanceof Object).toBeTruthy(); - expect(mr.required).toBeTruthy(); - - expect(service.type instanceof Object).toBeTruthy(); - expect(service.required).toBeTruthy(); - }); - }); - - describe('methods', () => { - let vm = createComponent(); - const deployment = deploymentMockData[0]; - - describe('formatDate', () => { - it('should work', () => { - const readable = getTimeago().format(deployment.deployed_at); - expect(vm.formatDate(deployment.deployed_at)).toEqual(readable); - }); - }); - - describe('hasExternalUrls', () => { - it('should return true', () => { - expect(vm.hasExternalUrls(deployment)).toBeTruthy(); - }); - - it('should return false when there is not enough information', () => { - expect(vm.hasExternalUrls()).toBeFalsy(); - expect(vm.hasExternalUrls({ external_url: 'Diplo' })).toBeFalsy(); - expect(vm.hasExternalUrls({ external_url_formatted: 'Diplo' })).toBeFalsy(); - }); - }); - - describe('hasDeploymentTime', () => { - it('should return true', () => { - expect(vm.hasDeploymentTime(deployment)).toBeTruthy(); - }); - - it('should return false when there is not enough information', () => { - expect(vm.hasDeploymentTime()).toBeFalsy(); - expect(vm.hasDeploymentTime({ deployed_at: 'Diplo' })).toBeFalsy(); - expect(vm.hasDeploymentTime({ deployed_at_formatted: 'Diplo' })).toBeFalsy(); - }); - }); - - describe('hasDeploymentMeta', () => { - it('should return true', () => { - expect(vm.hasDeploymentMeta(deployment)).toBeTruthy(); - }); - - it('should return false when there is not enough information', () => { - expect(vm.hasDeploymentMeta()).toBeFalsy(); - expect(vm.hasDeploymentMeta({ url: 'Diplo' })).toBeFalsy(); - expect(vm.hasDeploymentMeta({ name: 'Diplo' })).toBeFalsy(); - }); - }); - - describe('stopEnvironment', () => { - const url = '/foo/bar'; - const returnPromise = () => new Promise((resolve) => { - resolve({ - data: { - redirect_url: url, - }, - }); - }); - const mockStopEnvironment = () => { - vm.stopEnvironment(deploymentMockData); - return vm; - }; - - it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => { - spyOn(window, 'confirm').and.returnValue(true); - spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); - spyOn(urlUtils, 'visitUrl').and.returnValue(true); - vm = mockStopEnvironment(); - - expect(window.confirm).toHaveBeenCalled(); - expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); - setTimeout(() => { - expect(urlUtils.visitUrl).toHaveBeenCalledWith(url); - done(); - }, 333); - }); - - it('should show a confirm dialog but should not work if the dialog is rejected', () => { - spyOn(window, 'confirm').and.returnValue(false); - spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); - vm = mockStopEnvironment(); - - expect(window.confirm).toHaveBeenCalled(); - expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); - }); - }); - }); - - describe('template', () => { - let vm; - let el; - const [deployment] = deploymentMockData; - - beforeEach(() => { - vm = createComponent(deploymentMockData); - el = vm.$el; - }); - - it('should render template elements correctly', () => { - expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); - expect(el.querySelector('.js-icon-link')).toBeDefined(); - expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deployment.url); - expect(el.querySelector('.js-deploy-meta').innerText).toContain(deployment.name); - expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url); - expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted); - expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at)); - expect(el.querySelector('.js-mr-memory-usage')).toBeDefined(); - expect(el.querySelector('button')).toBeDefined(); - }); - - it('should list multiple deployments', (done) => { - vm.mr.deployments.push(deployment); - vm.mr.deployments.push(deployment); - - Vue.nextTick(() => { - expect(el.querySelectorAll('.ci-widget').length).toEqual(3); - expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3); - done(); - }); - }); - - it('should not have some elements when there is not enough data', (done) => { - vm.mr.deployments = [{}]; - - Vue.nextTick(() => { - expect(el.querySelectorAll('.js-deploy-meta').length).toEqual(0); - expect(el.querySelectorAll('.js-deploy-url').length).toEqual(0); - expect(el.querySelectorAll('.js-deploy-time').length).toEqual(0); - expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(0); - expect(el.querySelectorAll('.button').length).toEqual(0); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js index fe87f110354..046968fbc1f 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import unresolvedDiscussionsComponent from '~/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions'; +import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; -describe('MRWidgetUnresolvedDiscussions', () => { +describe('UnresolvedDiscussions', () => { describe('props', () => { it('should have props', () => { - const { mr } = unresolvedDiscussionsComponent.props; + const { mr } = UnresolvedDiscussions.props; expect(mr.type instanceof Object).toBeTruthy(); expect(mr.required).toBeTruthy(); @@ -17,7 +17,7 @@ describe('MRWidgetUnresolvedDiscussions', () => { const path = 'foo/bar'; beforeEach(() => { - const Component = Vue.extend(unresolvedDiscussionsComponent); + const Component = Vue.extend(UnresolvedDiscussions); const mr = { createIssueToResolveDiscussionsPath: path, }; diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 32876ca66ac..e55c7649d40 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -123,17 +123,6 @@ describe('mrWidgetOptions', () => { expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); }); }); - - describe('shouldRenderDeployments', () => { - it('should return false for the initial data', () => { - expect(vm.shouldRenderDeployments).toBeFalsy(); - }); - - it('should return true if there is deployments', () => { - vm.mr.deployments.push({}, {}); - expect(vm.shouldRenderDeployments).toBeTruthy(); - }); - }); }); describe('methods', () => { @@ -189,16 +178,16 @@ describe('mrWidgetOptions', () => { describe('fetchDeployments', () => { it('should fetch deployments', (done) => { - spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ deployment: 1 }])); + spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }])); vm.fetchDeployments(); setTimeout(() => { expect(vm.service.fetchDeployments).toHaveBeenCalled(); expect(vm.mr.deployments.length).toEqual(1); - expect(vm.mr.deployments[0].deployment).toEqual(1); + expect(vm.mr.deployments[0].id).toBe(1); done(); - }, 333); + }); }); }); @@ -368,34 +357,6 @@ describe('mrWidgetOptions', () => { }); }); - describe('components', () => { - it('should register all components', () => { - const comps = mrWidgetOptions.components; - expect(comps['mr-widget-header']).toBeDefined(); - expect(comps['mr-widget-merge-help']).toBeDefined(); - expect(comps['mr-widget-pipeline']).toBeDefined(); - expect(comps['mr-widget-deployment']).toBeDefined(); - expect(comps['mr-widget-related-links']).toBeDefined(); - expect(comps['mr-widget-merged']).toBeDefined(); - expect(comps['mr-widget-closed']).toBeDefined(); - expect(comps['mr-widget-merging']).toBeDefined(); - expect(comps['mr-widget-failed-to-merge']).toBeDefined(); - expect(comps['mr-widget-wip']).toBeDefined(); - expect(comps['mr-widget-archived']).toBeDefined(); - expect(comps['mr-widget-conflicts']).toBeDefined(); - expect(comps['mr-widget-nothing-to-merge']).toBeDefined(); - expect(comps['mr-widget-not-allowed']).toBeDefined(); - expect(comps['mr-widget-missing-branch']).toBeDefined(); - expect(comps['mr-widget-ready-to-merge']).toBeDefined(); - expect(comps['mr-widget-checking']).toBeDefined(); - expect(comps['mr-widget-unresolved-discussions']).toBeDefined(); - expect(comps['mr-widget-pipeline-blocked']).toBeDefined(); - expect(comps['mr-widget-pipeline-failed']).toBeDefined(); - expect(comps['mr-widget-merge-when-pipeline-succeeds']).toBeDefined(); - expect(comps['mr-widget-maintainer-edit']).toBeDefined(); - }); - }); - describe('rendering relatedLinks', () => { beforeEach((done) => { vm.mr.relatedLinks = { @@ -454,4 +415,34 @@ describe('mrWidgetOptions', () => { }); }); }); + + describe('rendering deployments', () => { + const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + }; + + beforeEach((done) => { + vm.mr.deployments.push({ + ...deploymentMockData, + }, { + ...deploymentMockData, + id: deploymentMockData.id + 1, + }); + + vm.$nextTick(done); + }); + + it('renders multiple deployments', () => { + expect(vm.$el.querySelectorAll('.deploy-heading').length).toBe(2); + }); + }); }); diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb index 3ca4652f7cc..ba8dc68ceda 100644 --- a/spec/lib/banzai/filter/relative_link_filter_spec.rb +++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb @@ -217,6 +217,23 @@ describe Banzai::Filter::RelativeLinkFilter do end end + context 'when ref name contains special chars' do + let(:ref) {'mark#\'@],+;-._/#@!$&()+down'} + + it 'correctly escapes the ref' do + # Adressable won't escape the '#', so we do this manually + ref_escaped = 'mark%23\'@%5D,+;-._/%23@!$&()+down' + + # Stub this method so the branch doesn't actually need to be in the repo + allow_any_instance_of(described_class).to receive(:uri_type).and_return(:raw) + + doc = filter(link('files/images/logo-black.png')) + + expect(doc.at_css('a')['href']) + .to eq "/#{project_path}/raw/#{ref_escaped}/files/images/logo-black.png" + end + end + context 'when requested path is a directory with space in the repo' do let(:ref) { 'master' } let(:commit) { project.commit('38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e') } diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb index 9f42cf1dfca..5cb1f4deb5f 100644 --- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb @@ -61,7 +61,7 @@ describe ::Gitlab::BareRepositoryImport::Repository do let(:wiki_path) { File.join(root_path, "#{hashed_path}.wiki.git") } before do - gitlab_shell.add_repository(repository_storage, hashed_path) + gitlab_shell.create_repository(repository_storage, hashed_path) repository = Rugged::Repository.new(repo_path) repository.config['gitlab.fullpath'] = 'to/repo' end diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb new file mode 100644 index 00000000000..cc1257484d2 --- /dev/null +++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Gitlab::Ci::Variables::Collection::Item do + let(:variable) do + { key: 'VAR', value: 'something', public: true } + end + + describe '.fabricate' do + it 'supports using a hash' do + resource = described_class.fabricate(variable) + + expect(resource).to be_a(described_class) + expect(resource).to eq variable + end + + it 'supports using an active record resource' do + variable = create(:ci_variable, key: 'CI_VAR', value: '123') + resource = described_class.fabricate(variable) + + expect(resource).to be_a(described_class) + expect(resource).to eq(key: 'CI_VAR', value: '123', public: false) + end + + it 'supports using another collection item' do + item = described_class.new(**variable) + + resource = described_class.fabricate(item) + + expect(resource).to be_a(described_class) + expect(resource).to eq variable + expect(resource.object_id).not_to eq item.object_id + end + end + + describe '#==' do + it 'compares a hash representation of a variable' do + expect(described_class.new(**variable) == variable).to be true + end + end + + describe '#[]' do + it 'behaves like a hash accessor' do + item = described_class.new(**variable) + + expect(item[:key]).to eq 'VAR' + end + end + + describe '#to_hash' do + it 'returns a hash representation of a collection item' do + expect(described_class.new(**variable).to_hash).to eq variable + end + end +end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb new file mode 100644 index 00000000000..90b6e178242 --- /dev/null +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe Gitlab::Ci::Variables::Collection do + describe '.new' do + it 'can be initialized with an array' do + variable = { key: 'VAR', value: 'value', public: true } + + collection = described_class.new([variable]) + + expect(collection.first.to_hash).to eq variable + end + + it 'can be initialized without an argument' do + expect(subject).to be_none + end + end + + describe '#append' do + it 'appends a hash' do + subject.append(key: 'VARIABLE', value: 'something') + + expect(subject).to be_one + end + + it 'appends a Ci::Variable' do + subject.append(build(:ci_variable)) + + expect(subject).to be_one + end + + it 'appends an internal resource' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + + subject.append(collection.first) + + expect(subject).to be_one + end + + it 'returns self' do + expect(subject.append(key: 'VAR', value: 'test')) + .to eq subject + end + end + + describe '#concat' do + it 'appends all elements from an array' do + collection = described_class.new([{ key: 'VAR_1', value: '1' }]) + variables = [{ key: 'VAR_2', value: '2' }, { key: 'VAR_3', value: '3' }] + + collection.concat(variables) + + expect(collection).to include(key: 'VAR_1', value: '1', public: true) + expect(collection).to include(key: 'VAR_2', value: '2', public: true) + expect(collection).to include(key: 'VAR_3', value: '3', public: true) + end + + it 'appends all elements from other collection' do + collection = described_class.new([{ key: 'VAR_1', value: '1' }]) + additional = described_class.new([{ key: 'VAR_2', value: '2' }, + { key: 'VAR_3', value: '3' }]) + + collection.concat(additional) + + expect(collection).to include(key: 'VAR_1', value: '1', public: true) + expect(collection).to include(key: 'VAR_2', value: '2', public: true) + expect(collection).to include(key: 'VAR_3', value: '3', public: true) + end + + it 'returns self' do + expect(subject.concat([key: 'VAR', value: 'test'])) + .to eq subject + end + end + + describe '#+' do + it 'makes it possible to combine with an array' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + variables = [{ key: 'TEST', value: 'something' }] + + expect((collection + variables).count).to eq 2 + end + + it 'makes it possible to combine with another collection' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + other = described_class.new([{ key: 'TEST', value: 2 }]) + + expect((collection + other).count).to eq 2 + end + end + + describe '#to_runner_variables' do + it 'creates an array of hashes in a runner-compatible format' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + + expect(collection.to_runner_variables) + .to eq [{ key: 'TEST', value: 1, public: true }] + end + end +end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index b2f13fae73f..1fe1d3926ad 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -287,6 +287,29 @@ describe Gitlab::Database do end end + describe '.cached_column_exists?' do + it 'only retrieves data once' do + expect(ActiveRecord::Base.connection).to receive(:columns).once.and_call_original + + 2.times do + expect(described_class.cached_column_exists?(:projects, :id)).to be_truthy + expect(described_class.cached_column_exists?(:projects, :bogus_column)).to be_falsey + end + end + end + + describe '.cached_table_exists?' do + it 'only retrieves data once per table' do + expect(ActiveRecord::Base.connection).to receive(:table_exists?).with(:projects).once.and_call_original + expect(ActiveRecord::Base.connection).to receive(:table_exists?).with(:bogus_table_name).once.and_call_original + + 2.times do + expect(described_class.cached_table_exists?(:projects)).to be_truthy + expect(described_class.cached_table_exists?(:bogus_table_name)).to be_falsey + end + end + end + describe '#true_value' do it 'returns correct value for PostgreSQL' do expect(described_class).to receive(:postgresql?).and_return(true) diff --git a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb index a067c42b75b..f48ee8924e8 100644 --- a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb +++ b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb @@ -12,7 +12,7 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do diff_files end - it 'does not files marked as undiffable in .gitattributes' do + it 'does not highlight files marked as undiffable in .gitattributes' do allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(false) expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines) diff --git a/spec/lib/gitlab/git/gitlab_projects_spec.rb b/spec/lib/gitlab/git/gitlab_projects_spec.rb index 45bcd730332..dfccc15a4f3 100644 --- a/spec/lib/gitlab/git/gitlab_projects_spec.rb +++ b/spec/lib/gitlab/git/gitlab_projects_spec.rb @@ -28,7 +28,7 @@ describe Gitlab::Git::GitlabProjects do describe '#push_branches' do let(:remote_name) { 'remote-name' } let(:branch_name) { 'master' } - let(:cmd) { %W(git push -- #{remote_name} #{branch_name}) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} push -- #{remote_name} #{branch_name}) } let(:force) { false } subject { gl_projects.push_branches(remote_name, 600, force, [branch_name]) } @@ -46,7 +46,7 @@ describe Gitlab::Git::GitlabProjects do end context 'with --force' do - let(:cmd) { %W(git push --force -- #{remote_name} #{branch_name}) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} push --force -- #{remote_name} #{branch_name}) } let(:force) { true } it 'executes the command' do @@ -65,7 +65,7 @@ describe Gitlab::Git::GitlabProjects do let(:tags) { true } let(:args) { { force: force, tags: tags, prune: prune }.merge(extra_args) } let(:extra_args) { {} } - let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --tags) } subject { gl_projects.fetch_remote(remote_name, 600, args) } @@ -98,7 +98,7 @@ describe Gitlab::Git::GitlabProjects do context 'with --force' do let(:force) { true } - let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --force --tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --force --tags) } it 'executes the command with forced option' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -109,7 +109,7 @@ describe Gitlab::Git::GitlabProjects do context 'with --no-tags' do let(:tags) { false } - let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --no-tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --no-tags) } it 'executes the command' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -120,7 +120,7 @@ describe Gitlab::Git::GitlabProjects do context 'with no prune' do let(:prune) { false } - let(:cmd) { %W(git fetch #{remote_name} --quiet --tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --tags) } it 'executes the command' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -165,7 +165,7 @@ describe Gitlab::Git::GitlabProjects do describe '#import_project' do let(:project) { create(:project) } let(:import_url) { TestEnv.factory_repo_path_bare } - let(:cmd) { %W(git clone --bare -- #{import_url} #{tmp_repo_path}) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} clone --bare -- #{import_url} #{tmp_repo_path}) } let(:timeout) { 600 } subject { gl_projects.import_project(import_url, timeout) } diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 52c9876cbb6..54ada3e423f 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -681,7 +681,7 @@ describe Gitlab::Git::Repository, seed_helper: true do subject { new_repository.fetch_repository_as_mirror(repository) } before do - Gitlab::Shell.new.add_repository('default', 'my_project') + Gitlab::Shell.new.create_repository('default', 'my_project') end after do diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 62ef93f847a..4a51777ba9b 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -43,7 +43,6 @@ { "id": 40, "title": "Voluptatem", - "assignee_id": 1, "author_id": 22, "project_id": 5, "created_at": "2016-06-14T15:02:08.340Z", @@ -61,7 +60,23 @@ "issue_assignees": [ { "user_id": 1, - "issue_id": 1 + "issue_id": 40 + }, + { + "user_id": 15, + "issue_id": 40 + }, + { + "user_id": 16, + "issue_id": 40 + }, + { + "user_id": 16, + "issue_id": 40 + }, + { + "user_id": 6, + "issue_id": 40 } ], "milestone": { @@ -319,8 +334,7 @@ }, { "id": 39, - "title": "Delectus veniam ratione in eos culpa et natus molestiae earum aut.", - "assignee_id": 20, + "title": "Issue without assignees", "author_id": 22, "project_id": 5, "created_at": "2016-06-14T15:02:08.233Z", @@ -334,6 +348,7 @@ "confidential": false, "due_date": null, "moved_to_id": null, + "issue_assignees": [], "milestone": { "id": 1, "title": "test milestone", @@ -539,7 +554,6 @@ { "id": 38, "title": "Quasi adipisci non cupiditate dolorem quo qui earum sed.", - "assignee_id": 1, "author_id": 6, "project_id": 5, "created_at": "2016-06-14T15:02:08.154Z", @@ -756,7 +770,6 @@ { "id": 37, "title": "Cupiditate quo aut ducimus minima molestiae vero numquam possimus.", - "assignee_id": 15, "author_id": 20, "project_id": 5, "created_at": "2016-06-14T15:02:08.051Z", @@ -952,7 +965,6 @@ { "id": 36, "title": "Necessitatibus dolor est enim quia rem suscipit quidem voluptas ullam.", - "assignee_id": 20, "author_id": 16, "project_id": 5, "created_at": "2016-06-14T15:02:07.958Z", @@ -1148,7 +1160,6 @@ { "id": 35, "title": "Repellat praesentium deserunt maxime incidunt harum porro qui.", - "assignee_id": 6, "author_id": 20, "project_id": 5, "created_at": "2016-06-14T15:02:07.832Z", @@ -1344,7 +1355,6 @@ { "id": 34, "title": "Ullam expedita deserunt libero consequatur quia dolor harum perferendis facere quidem.", - "assignee_id": 20, "author_id": 1, "project_id": 5, "created_at": "2016-06-14T15:02:07.717Z", @@ -1540,7 +1550,6 @@ { "id": 33, "title": "Numquam accusamus eos iste exercitationem magni non inventore.", - "assignee_id": 15, "author_id": 26, "project_id": 5, "created_at": "2016-06-14T15:02:07.611Z", @@ -1736,7 +1745,6 @@ { "id": 32, "title": "Necessitatibus magnam qui at velit consequatur perspiciatis.", - "assignee_id": 22, "author_id": 15, "project_id": 5, "created_at": "2016-06-14T15:02:07.431Z", @@ -1932,7 +1940,6 @@ { "id": 31, "title": "Libero nam magnam incidunt eaque placeat error et.", - "assignee_id": 1, "author_id": 16, "project_id": 5, "created_at": "2016-06-14T15:02:07.280Z", diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index f4e466d1296..8e25cd26c2f 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -4,7 +4,12 @@ include ImportExport::CommonUtil describe Gitlab::ImportExport::ProjectTreeRestorer do describe 'restore project tree' do before(:context) do - @user = create(:user) + # Using an admin for import, so we can check assignment of existing members + @user = create(:admin) + @existing_members = [ + create(:user, username: 'bernard_willms'), + create(:user, username: 'saul_will') + ] RSpec::Mocks.with_temporary_scope do @project = create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') @@ -37,6 +42,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED) end + it 'has the project description' do + expect(Project.find_by_path('project').description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.') + end + it 'has the project html description' do expect(Project.find_by_path('project').description_html).to eq('description') end @@ -63,8 +72,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(issue.reload.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC') end - it 'has issue assignees' do - expect(Issue.where(title: 'Voluptatem').first.issue_assignees).not_to be_empty + it 'has multiple issue assignees' do + expect(Issue.find_by(title: 'Voluptatem').assignees).to contain_exactly(@user, *@existing_members) + expect(Issue.find_by(title: 'Issue without assignees').assignees).to be_empty end it 'contains the merge access levels on a protected branch' do diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 3049491f0ae..0d20a551e2a 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -29,8 +29,17 @@ describe Gitlab::ImportExport::ProjectTreeSaver do project_json(project_tree_saver.full_path) end + context 'with description override' do + let(:params) { { description: 'Foo Bar' } } + let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared, params: params) } + + it 'overrides the project description' do + expect(saved_project_json).to include({ 'description' => params[:description] }) + end + end + it 'saves the correct json' do - expect(saved_project_json).to include({ "visibility_level" => 20 }) + expect(saved_project_json).to include({ 'description' => 'description', 'visibility_level' => 20 }) end it 'has milestones' do @@ -259,6 +268,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver do :issues_disabled, :wiki_enabled, :builds_private, + description: 'description', issues: [issue], snippets: [snippet], releases: [release], diff --git a/spec/lib/gitlab/kubernetes/namespace_spec.rb b/spec/lib/gitlab/kubernetes/namespace_spec.rb index b3c987f9344..e098612f6fb 100644 --- a/spec/lib/gitlab/kubernetes/namespace_spec.rb +++ b/spec/lib/gitlab/kubernetes/namespace_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::Kubernetes::Namespace do describe '#exists?' do context 'when namespace do not exits' do - let(:exception) { ::KubeException.new(404, "namespace #{name} not found", nil) } + let(:exception) { ::Kubeclient::HttpError.new(404, "namespace #{name} not found", nil) } it 'returns false' do expect(client).to receive(:get_namespace).with(name).once.and_raise(exception) diff --git a/spec/lib/gitlab/project_transfer_spec.rb b/spec/lib/gitlab/project_transfer_spec.rb index 10c5fb148cd..0b9b1f537b5 100644 --- a/spec/lib/gitlab/project_transfer_spec.rb +++ b/spec/lib/gitlab/project_transfer_spec.rb @@ -21,30 +21,77 @@ describe Gitlab::ProjectTransfer do describe '#move_project' do it "moves project upload to another namespace" do - FileUtils.mkdir_p(File.join(@root_dir, @namespace_path_was, @project_path)) + path_to_be_moved = File.join(@root_dir, @namespace_path_was, @project_path) + expected_path = File.join(@root_dir, @namespace_path, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + @project_transfer.move_project(@project_path, @namespace_path_was, @namespace_path) - expected_path = File.join(@root_dir, @namespace_path, @project_path) expect(Dir.exist?(expected_path)).to be_truthy end end + describe '#move_namespace' do + context 'when moving namespace from root into another namespace' do + it "moves namespace projects' upload" do + child_namespace = 'test_child_namespace' + path_to_be_moved = File.join(@root_dir, child_namespace, @project_path) + expected_path = File.join(@root_dir, @namespace_path, child_namespace, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + + @project_transfer.move_namespace(child_namespace, nil, @namespace_path) + + expect(Dir.exist?(expected_path)).to be_truthy + end + end + + context 'when moving namespace from one parent to another' do + it "moves namespace projects' upload" do + child_namespace = 'test_child_namespace' + path_to_be_moved = File.join(@root_dir, @namespace_path_was, child_namespace, @project_path) + expected_path = File.join(@root_dir, @namespace_path, child_namespace, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + + @project_transfer.move_namespace(child_namespace, @namespace_path_was, @namespace_path) + + expect(Dir.exist?(expected_path)).to be_truthy + end + end + + context 'when moving namespace from having a parent to root' do + it "moves namespace projects' upload" do + child_namespace = 'test_child_namespace' + path_to_be_moved = File.join(@root_dir, @namespace_path_was, child_namespace, @project_path) + expected_path = File.join(@root_dir, child_namespace, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + + @project_transfer.move_namespace(child_namespace, @namespace_path_was, nil) + + expect(Dir.exist?(expected_path)).to be_truthy + end + end + end + describe '#rename_project' do it "renames project" do - FileUtils.mkdir_p(File.join(@root_dir, @namespace_path, @project_path_was)) + path_to_be_moved = File.join(@root_dir, @namespace_path, @project_path_was) + expected_path = File.join(@root_dir, @namespace_path, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + @project_transfer.rename_project(@project_path_was, @project_path, @namespace_path) - expected_path = File.join(@root_dir, @namespace_path, @project_path) expect(Dir.exist?(expected_path)).to be_truthy end end describe '#rename_namespace' do it "renames namespace" do - FileUtils.mkdir_p(File.join(@root_dir, @namespace_path_was, @project_path)) + path_to_be_moved = File.join(@root_dir, @namespace_path_was, @project_path) + expected_path = File.join(@root_dir, @namespace_path, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + @project_transfer.rename_namespace(@namespace_path_was, @namespace_path) - expected_path = File.join(@root_dir, @namespace_path, @project_path) expect(Dir.exist?(expected_path)).to be_truthy end end diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 56b45d8da3c..14b59c5e945 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::Shell do it { is_expected.to respond_to :add_key } it { is_expected.to respond_to :remove_key } - it { is_expected.to respond_to :add_repository } + it { is_expected.to respond_to :create_repository } it { is_expected.to respond_to :remove_repository } it { is_expected.to respond_to :fork_repository } @@ -402,8 +402,8 @@ describe Gitlab::Shell do allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800) end - describe '#add_repository' do - shared_examples '#add_repository' do + describe '#create_repository' do + shared_examples '#create_repository' do let(:repository_storage) { 'default' } let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } let(:repo_name) { 'project/path' } @@ -414,7 +414,7 @@ describe Gitlab::Shell do end it 'creates a repository' do - expect(gitlab_shell.add_repository(repository_storage, repo_name)).to be_truthy + expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_truthy expect(File.stat(created_path).mode & 0o777).to eq(0o770) @@ -426,19 +426,19 @@ describe Gitlab::Shell do it 'returns false when the command fails' do FileUtils.mkdir_p(File.dirname(created_path)) # This file will block the creation of the repo's .git directory. That - # should cause #add_repository to fail. + # should cause #create_repository to fail. FileUtils.touch(created_path) - expect(gitlab_shell.add_repository(repository_storage, repo_name)).to be_falsy + expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_falsy end end context 'with gitaly' do - it_behaves_like '#add_repository' + it_behaves_like '#create_repository' end context 'without gitaly', :skip_gitaly_mock do - it_behaves_like '#add_repository' + it_behaves_like '#create_repository' end end diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb index e3447d974aa..194cae8c645 100644 --- a/spec/lib/gitlab/slash_commands/command_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_spec.rb @@ -108,5 +108,10 @@ describe Gitlab::SlashCommands::Command do it { is_expected.to eq(Gitlab::SlashCommands::IssueSearch) } end + + context 'IssueMove is triggered' do + let(:params) { { text: 'issue move #78291 to gitlab/gitlab-ci' } } + it { is_expected.to eq(Gitlab::SlashCommands::IssueMove) } + end end end diff --git a/spec/lib/gitlab/slash_commands/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/issue_move_spec.rb new file mode 100644 index 00000000000..d41441c9472 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/issue_move_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::IssueMove, service: true do + describe '#match' do + shared_examples_for 'move command' do |text_command| + it 'can be parsed to extract the needed fields' do + match_data = described_class.match(text_command) + + expect(match_data['iid']).to eq('123456') + expect(match_data['project_path']).to eq('gitlab/gitlab-ci') + end + end + + it_behaves_like 'move command', 'issue move #123456 to gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci ' + it_behaves_like 'move command', 'issue move 123456 to gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci ' + end + + describe '#execute' do + set(:user) { create(:user) } + set(:issue) { create(:issue) } + set(:chat_name) { create(:chat_name, user: user) } + set(:project) { issue.project } + set(:other_project) { create(:project, namespace: project.namespace) } + + before do + [project, other_project].each { |prj| prj.add_master(user) } + end + + subject { described_class.new(project, chat_name) } + + def process_message(message) + subject.execute(described_class.match(message)) + end + + context 'when the user can move the issue' do + context 'when the move fails' do + it 'returns the error message' do + message = "issue move #{issue.iid} #{project.full_path}" + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('Cannot move issue')) + end + end + + context 'when the move succeeds' do + let(:message) { "issue move #{issue.iid} #{other_project.full_path}" } + + it 'moves the issue to the new destination' do + expect { process_message(message) }.to change { Issue.count }.by(1) + + new_issue = issue.reload.moved_to + + expect(new_issue.state).to eq('opened') + expect(new_issue.project_id).to eq(other_project.id) + expect(new_issue.author_id).to eq(issue.author_id) + + expect(issue.state).to eq('closed') + expect(issue.project_id).to eq(project.id) + end + + it 'returns the new issue' do + expect(process_message(message)) + .to include(response_type: :in_channel, + attachments: [a_hash_including(title_link: a_string_including(other_project.full_path))]) + end + + it 'mentions the old issue' do + expect(process_message(message)) + .to include(attachments: [a_hash_including(pretext: a_string_including(project.full_path))]) + end + end + end + + context 'when the issue does not exist' do + it 'returns not found' do + message = "issue move #{issue.iid.succ} #{other_project.full_path}" + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('not found')) + end + end + + context 'when the target project does not exist' do + it 'returns not found' do + message = "issue move #{issue.iid} #{other_project.full_path}/foo" + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('not found')) + end + end + + context 'when the user cannot see the target project' do + it 'returns not found' do + message = "issue move #{issue.iid} #{other_project.full_path}" + other_project.team.truncate + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('not found')) + end + end + + context 'when the user does not have the required permissions on the target project' do + it 'returns the error message' do + message = "issue move #{issue.iid} #{other_project.full_path}" + other_project.team.truncate + other_project.team.add_guest(user) + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('Cannot move issue')) + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb new file mode 100644 index 00000000000..58c341a284e --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::IssueMove do + set(:admin) { create(:admin) } + set(:project) { create(:project) } + set(:other_project) { create(:project) } + set(:old_issue) { create(:issue, project: project) } + set(:new_issue) { Issues::MoveService.new(project, admin).execute(old_issue, other_project) } + let(:attachment) { subject[:attachments].first } + + subject { described_class.new(new_issue).present(old_issue) } + + it { is_expected.to be_a(Hash) } + + it 'shows the new issue' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject).to have_key(:attachments) + expect(attachment[:title]).to start_with(new_issue.title) + expect(attachment[:title_link]).to include(other_project.full_path) + end + + it 'mentions the old issue and the new issue in the pretext' do + expect(attachment[:pretext]).to include(project.full_path) + expect(attachment[:pretext]).to include(other_project.full_path) + end +end diff --git a/spec/migrations/migrate_old_artifacts_spec.rb b/spec/migrations/migrate_old_artifacts_spec.rb index 92eb1d9ce86..638b2853374 100644 --- a/spec/migrations/migrate_old_artifacts_spec.rb +++ b/spec/migrations/migrate_old_artifacts_spec.rb @@ -66,7 +66,7 @@ describe MigrateOldArtifacts do end it 'all files do have artifacts' do - Ci::Build.with_artifacts do |build| + Ci::Build.with_artifacts_archive do |build| expect(build).to have_artifacts end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index c27313ed88b..9da3de7a828 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -80,6 +80,42 @@ describe Ci::Build do end end + describe '.with_artifacts_archive' do + subject { described_class.with_artifacts_archive } + + context 'when job does not have an archive' do + let!(:job) { create(:ci_build) } + + it 'does not return the job' do + is_expected.not_to include(job) + end + end + + context 'when job has a legacy archive' do + let!(:job) { create(:ci_build, :legacy_artifacts) } + + it 'returns the job' do + is_expected.to include(job) + end + end + + context 'when job has a job artifact archive' do + let!(:job) { create(:ci_build, :artifacts) } + + it 'returns the job' do + is_expected.to include(job) + end + end + + context 'when job has a job artifact trace' do + let!(:job) { create(:ci_build, :trace_artifact) } + + it 'does not return the job' do + is_expected.not_to include(job) + end + end + end + describe '#actionize' do context 'when build is a created' do before do @@ -679,21 +715,21 @@ describe Ci::Build do describe '#erase' do before do - build.erase(erased_by: user) + build.erase(erased_by: erased_by) end context 'erased by user' do - let!(:user) { create(:user, username: 'eraser') } + let!(:erased_by) { create(:user, username: 'eraser') } include_examples 'erasable' it 'records user who erased a build' do - expect(build.erased_by).to eq user + expect(build.erased_by).to eq erased_by end end context 'erased by system' do - let(:user) { nil } + let(:erased_by) { nil } include_examples 'erasable' @@ -748,21 +784,21 @@ describe Ci::Build do describe '#erase' do before do - build.erase(erased_by: user) + build.erase(erased_by: erased_by) end context 'erased by user' do - let!(:user) { create(:user, username: 'eraser') } + let!(:erased_by) { create(:user, username: 'eraser') } include_examples 'erasable' it 'records user who erased a build' do - expect(build.erased_by).to eq user + expect(build.erased_by).to eq erased_by end end context 'erased by system' do - let(:user) { nil } + let(:erased_by) { nil } include_examples 'erasable' @@ -1424,6 +1460,17 @@ describe Ci::Build do { key: 'CI_COMMIT_SHA', value: build.sha, public: true }, { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true }, { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true }, + { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, + { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }, + { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, + { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, + { key: 'CI_BUILD_REF', value: build.sha, public: true }, + { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, + { key: 'CI_BUILD_REF_NAME', value: build.ref, public: true }, + { key: 'CI_BUILD_REF_SLUG', value: build.ref_slug, public: true }, + { key: 'CI_BUILD_NAME', value: 'test', public: true }, + { key: 'CI_BUILD_STAGE', value: 'test', public: true }, { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: project.path, public: true }, { key: 'CI_PROJECT_PATH', value: project.full_path, public: true }, @@ -1433,9 +1480,7 @@ describe Ci::Build do { key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true }, { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, { key: 'CI_CONFIG_PATH', value: pipeline.ci_yaml_file_path, public: true }, - { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, - { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, - { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false } + { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true } ] end @@ -1834,39 +1879,6 @@ describe Ci::Build do it { is_expected.to include(ci_config_path) } end - context 'returns variables in valid order' do - let(:build_pre_var) { { key: 'build', value: 'value' } } - let(:project_pre_var) { { key: 'project', value: 'value' } } - let(:pipeline_pre_var) { { key: 'pipeline', value: 'value' } } - let(:build_yaml_var) { { key: 'yaml', value: 'value' } } - - before do - allow(build).to receive(:predefined_variables) { [build_pre_var] } - allow(build).to receive(:yaml_variables) { [build_yaml_var] } - - allow_any_instance_of(Project) - .to receive(:predefined_variables) { [project_pre_var] } - - allow_any_instance_of(Project) - .to receive(:secret_variables_for) - .with(ref: 'master', environment: nil) do - [create(:ci_variable, key: 'secret', value: 'value')] - end - - allow_any_instance_of(Ci::Pipeline) - .to receive(:predefined_variables) { [pipeline_pre_var] } - end - - it do - is_expected.to eq( - [build_pre_var, - project_pre_var, - pipeline_pre_var, - build_yaml_var, - { key: 'secret', value: 'value', public: false }]) - end - end - context 'when using auto devops' do context 'and is enabled' do before do @@ -1890,6 +1902,81 @@ describe Ci::Build do end end end + + context 'when pipeline variable overrides build variable' do + before do + build.yaml_variables = [{ key: 'MYVAR', value: 'myvar', public: true }] + pipeline.variables.build(key: 'MYVAR', value: 'pipeline value') + end + + it 'overrides YAML variable using a pipeline variable' do + variables = subject.reverse.uniq { |variable| variable[:key] }.reverse + + expect(variables) + .not_to include(key: 'MYVAR', value: 'myvar', public: true) + expect(variables) + .to include(key: 'MYVAR', value: 'pipeline value', public: false) + end + end + + describe 'variables ordering' do + context 'when variables hierarchy is stubbed' do + let(:build_pre_var) { { key: 'build', value: 'value', public: true } } + let(:project_pre_var) { { key: 'project', value: 'value', public: true } } + let(:pipeline_pre_var) { { key: 'pipeline', value: 'value', public: true } } + let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true } } + + before do + allow(build).to receive(:predefined_variables) { [build_pre_var] } + allow(build).to receive(:yaml_variables) { [build_yaml_var] } + + allow_any_instance_of(Project) + .to receive(:predefined_variables) { [project_pre_var] } + + allow_any_instance_of(Project) + .to receive(:secret_variables_for) + .with(ref: 'master', environment: nil) do + [create(:ci_variable, key: 'secret', value: 'value')] + end + + allow_any_instance_of(Ci::Pipeline) + .to receive(:predefined_variables) { [pipeline_pre_var] } + end + + it 'returns variables in order depending on resource hierarchy' do + is_expected.to eq( + [build_pre_var, + project_pre_var, + pipeline_pre_var, + build_yaml_var, + { key: 'secret', value: 'value', public: false }]) + end + end + + context 'when build has environment and user-provided variables' do + let(:expected_variables) do + predefined_variables.map { |variable| variable.fetch(:key) } + + %w[YAML_VARIABLE CI_ENVIRONMENT_NAME CI_ENVIRONMENT_SLUG + CI_ENVIRONMENT_URL] + end + + before do + create(:environment, project: build.project, + name: 'staging') + + build.yaml_variables = [{ key: 'YAML_VARIABLE', + value: 'var', + public: true }] + build.environment = 'staging' + end + + it 'matches explicit variables ordering' do + received_variables = subject.map { |variable| variable.fetch(:key) } + + expect(received_variables).to eq expected_variables + end + end + end end describe 'state transition: any => [:pending]' do @@ -1907,7 +1994,7 @@ describe Ci::Build do context 'when depended job has not been completed yet' do let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) } - it { expect { job.run! }.not_to raise_error(Ci::Build::MissingDependenciesError) } + it { expect { job.run! }.not_to raise_error } end context 'when artifacts of depended job has been expired' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 14d234f6aab..4635f8cfe9d 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -170,12 +170,10 @@ describe Ci::Pipeline, :mailer do describe '#predefined_variables' do subject { pipeline.predefined_variables } - it { is_expected.to be_an(Array) } - - it 'includes the defined keys' do - keys = subject.map { |v| v[:key] } + it 'includes all predefined variables in a valid order' do + keys = subject.map { |variable| variable[:key] } - expect(keys).to include('CI_PIPELINE_ID', 'CI_CONFIG_PATH', 'CI_PIPELINE_SOURCE') + expect(keys).to eq %w[CI_PIPELINE_ID CI_CONFIG_PATH CI_PIPELINE_SOURCE] end end diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index 53a4e545ff6..add481b8096 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -252,7 +252,7 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching stub_kubeclient_pods(status: 500) end - it { expect { subject }.to raise_error(KubeException) } + it { expect { subject }.to raise_error(Kubeclient::HttpError) } end context 'when kubernetes responds with 404s' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 7986aa31e16..4e783acbd8b 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1544,7 +1544,7 @@ describe MergeRequest do end it "executes diff cache service" do - expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject) + expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject, an_instance_of(MergeRequestDiff)) subject.reload_diff end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e626efd054d..ee142718f7e 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -204,43 +204,67 @@ describe Namespace do expect(gitlab_shell.exists?(project.repository_storage_path, "#{namespace.path}/#{project.path}.git")).to be_truthy end - context 'with subgroups' do + context 'with subgroups', :nested_groups do let(:parent) { create(:group, name: 'parent', path: 'parent') } + let(:new_parent) { create(:group, name: 'new_parent', path: 'new_parent') } let(:child) { create(:group, name: 'child', path: 'child', parent: parent) } let!(:project) { create(:project_empty_repo, :legacy_storage, path: 'the-project', namespace: child, skip_disk_validation: true) } let(:uploads_dir) { FileUploader.root } let(:pages_dir) { File.join(TestEnv.pages_path) } + def expect_project_directories_at(namespace_path) + expected_repository_path = File.join(TestEnv.repos_path, namespace_path, 'the-project.git') + expected_upload_path = File.join(uploads_dir, namespace_path, 'the-project') + expected_pages_path = File.join(pages_dir, namespace_path, 'the-project') + + expect(File.directory?(expected_repository_path)).to be_truthy + expect(File.directory?(expected_upload_path)).to be_truthy + expect(File.directory?(expected_pages_path)).to be_truthy + end + before do + FileUtils.mkdir_p(File.join(TestEnv.repos_path, "#{project.full_path}.git")) FileUtils.mkdir_p(File.join(uploads_dir, project.full_path)) FileUtils.mkdir_p(File.join(pages_dir, project.full_path)) end context 'renaming child' do it 'correctly moves the repository, uploads and pages' do - expected_repository_path = File.join(TestEnv.repos_path, 'parent', 'renamed', 'the-project.git') - expected_upload_path = File.join(uploads_dir, 'parent', 'renamed', 'the-project') - expected_pages_path = File.join(pages_dir, 'parent', 'renamed', 'the-project') + child.update!(path: 'renamed') - child.update_attributes!(path: 'renamed') - - expect(File.directory?(expected_repository_path)).to be(true) - expect(File.directory?(expected_upload_path)).to be(true) - expect(File.directory?(expected_pages_path)).to be(true) + expect_project_directories_at('parent/renamed') end end context 'renaming parent' do it 'correctly moves the repository, uploads and pages' do - expected_repository_path = File.join(TestEnv.repos_path, 'renamed', 'child', 'the-project.git') - expected_upload_path = File.join(uploads_dir, 'renamed', 'child', 'the-project') - expected_pages_path = File.join(pages_dir, 'renamed', 'child', 'the-project') + parent.update!(path: 'renamed') + + expect_project_directories_at('renamed/child') + end + end + + context 'moving from one parent to another' do + it 'correctly moves the repository, uploads and pages' do + child.update!(parent: new_parent) - parent.update_attributes!(path: 'renamed') + expect_project_directories_at('new_parent/child') + end + end + + context 'moving from having a parent to root' do + it 'correctly moves the repository, uploads and pages' do + child.update!(parent: nil) + + expect_project_directories_at('child') + end + end + + context 'moving from root to having a parent' do + it 'correctly moves the repository, uploads and pages' do + parent.update!(parent: new_parent) - expect(File.directory?(expected_repository_path)).to be(true) - expect(File.directory?(expected_upload_path)).to be(true) - expect(File.directory?(expected_pages_path)).to be(true) + expect_project_directories_at('new_parent/parent/child') end end end @@ -525,7 +549,6 @@ describe Namespace do end end - # Note: Group transfers are not yet implemented context 'when a group is transferred into a root group' do context 'when the root group "Share with group lock" is enabled' do let(:root_group) { create(:group, share_with_group_lock: true) } diff --git a/spec/models/project_auto_devops_spec.rb b/spec/models/project_auto_devops_spec.rb index 296b91a771c..7545c0797e9 100644 --- a/spec/models/project_auto_devops_spec.rb +++ b/spec/models/project_auto_devops_spec.rb @@ -36,14 +36,14 @@ describe ProjectAutoDevops do end end - describe '#variables' do + describe '#predefined_variables' do let(:auto_devops) { build_stubbed(:project_auto_devops, project: project, domain: domain) } context 'when domain is defined' do let(:domain) { 'example.com' } it 'returns AUTO_DEVOPS_DOMAIN' do - expect(auto_devops.variables).to include(domain_variable) + expect(auto_devops.predefined_variables).to include(domain_variable) end end @@ -55,7 +55,7 @@ describe ProjectAutoDevops do allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return('example.com') end - it { expect(auto_devops.variables).to include(domain_variable) } + it { expect(auto_devops.predefined_variables).to include(domain_variable) } end context 'when there is no instance domain specified' do @@ -63,7 +63,7 @@ describe ProjectAutoDevops do allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return(nil) end - it { expect(auto_devops.variables).not_to include(domain_variable) } + it { expect(auto_devops.predefined_variables).not_to include(domain_variable) } end end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 622d8844a72..3be023a48c1 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -370,7 +370,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do stub_kubeclient_pods(status: 500) end - it { expect { subject }.to raise_error(KubeException) } + it { expect { subject }.to raise_error(Kubeclient::HttpError) } end context 'when kubernetes responds with 404s' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e970cd7dfdb..4cf8d861595 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1378,7 +1378,7 @@ describe Project do context 'using a regular repository' do it 'creates the repository' do - expect(shell).to receive(:add_repository) + expect(shell).to receive(:create_repository) .with(project.repository_storage, project.disk_path) .and_return(true) @@ -1388,7 +1388,7 @@ describe Project do end it 'adds an error if the repository could not be created' do - expect(shell).to receive(:add_repository) + expect(shell).to receive(:create_repository) .with(project.repository_storage, project.disk_path) .and_return(false) @@ -1402,7 +1402,7 @@ describe Project do context 'using a forked repository' do it 'does nothing' do expect(project).to receive(:forked?).and_return(true) - expect(shell).not_to receive(:add_repository) + expect(shell).not_to receive(:create_repository) project.create_repository end @@ -1421,7 +1421,7 @@ describe Project do allow(project).to receive(:repository_exists?) .and_return(false) - allow(shell).to receive(:add_repository) + allow(shell).to receive(:create_repository) .with(project.repository_storage_path, project.disk_path) .and_return(true) @@ -1445,7 +1445,7 @@ describe Project do allow(project).to receive(:repository_exists?) .and_return(false) - expect(shell).to receive(:add_repository) + expect(shell).to receive(:create_repository) .with(project.repository_storage, project.disk_path) .and_return(true) diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 8b4b5873704..d87c1ca14f0 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -74,7 +74,7 @@ describe ProjectWiki do # Create a fresh project which will not have a wiki project_wiki = described_class.new(create(:project), user) gitlab_shell = double(:gitlab_shell) - allow(gitlab_shell).to receive(:add_repository) + allow(gitlab_shell).to receive(:create_repository) allow(project_wiki).to receive(:gitlab_shell).and_return(gitlab_shell) expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError) diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index fbed527963f..12583109b59 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -285,6 +285,17 @@ describe API::ProjectExport do context 'when user is not a member' do it_behaves_like 'post project export start not found' end + + context 'when overriding description' do + it 'starts' do + params = { description: "Foo" } + + expect_any_instance_of(Projects::ImportExport::ExportService).to receive(:execute) + post api(path, project.owner), params + + expect(response).to have_gitlab_http_status(202) + end + end end end end diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb index ad175226e92..93199964a0e 100644 --- a/spec/services/clusters/applications/install_service_spec.rb +++ b/spec/services/clusters/applications/install_service_spec.rb @@ -34,7 +34,7 @@ describe Clusters::Applications::InstallService do context 'when k8s cluster communication fails' do before do - error = KubeException.new(500, 'system failure', nil) + error = Kubeclient::HttpError.new(500, 'system failure', nil) expect(helm_client).to receive(:install).with(install_command).and_raise(error) end diff --git a/spec/services/files/create_service_spec.rb b/spec/services/files/create_service_spec.rb index 030263b1502..abe99b9e794 100644 --- a/spec/services/files/create_service_spec.rb +++ b/spec/services/files/create_service_spec.rb @@ -43,7 +43,7 @@ describe Files::CreateService do blob = repository.blob_at('lfs', file_path) - expect(blob.data).not_to start_with('version https://git-lfs.github.com/spec/v1') + expect(blob.data).not_to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) expect(blob.data).to eq(file_content) end end @@ -58,7 +58,7 @@ describe Files::CreateService do blob = repository.blob_at('lfs', file_path) - expect(blob.data).to start_with('version https://git-lfs.github.com/spec/v1') + expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) end it "creates an LfsObject with the file's content" do diff --git a/spec/services/files/multi_service_spec.rb b/spec/services/files/multi_service_spec.rb index b9971776b33..59984c10990 100644 --- a/spec/services/files/multi_service_spec.rb +++ b/spec/services/files/multi_service_spec.rb @@ -4,28 +4,30 @@ describe Files::MultiService do subject { described_class.new(project, user, commit_params) } let(:project) { create(:project, :repository) } + let(:repository) { project.repository } let(:user) { create(:user) } let(:branch_name) { project.default_branch } let(:original_file_path) { 'files/ruby/popen.rb' } let(:new_file_path) { 'files/ruby/popen.rb' } + let(:file_content) { 'New content' } let(:action) { 'update' } let!(:original_commit_id) do Gitlab::Git::Commit.last_for_path(project.repository, branch_name, original_file_path).sha end - let(:actions) do - [ - { - action: action, - file_path: new_file_path, - previous_path: original_file_path, - content: 'New content', - last_commit_id: original_commit_id - } - ] + let(:default_action) do + { + action: action, + file_path: new_file_path, + previous_path: original_file_path, + content: file_content, + last_commit_id: original_commit_id + } end + let(:actions) { [default_action] } + let(:commit_params) do { commit_message: "Update File", @@ -110,6 +112,56 @@ describe Files::MultiService do end end + context 'when creating a file matching an LFS filter' do + let(:action) { 'create' } + let(:branch_name) { 'lfs' } + let(:new_file_path) { 'test_file.lfs' } + + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + it 'creates an LFS pointer' do + subject.execute + + blob = repository.blob_at('lfs', new_file_path) + + expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + end + + it "creates an LfsObject with the file's content" do + subject.execute + + expect(LfsObject.last.file.read).to eq file_content + end + + context 'with base64 encoded content' do + let(:raw_file_content) { 'Raw content' } + let(:file_content) { Base64.encode64(raw_file_content) } + let(:actions) { [default_action.merge(encoding: 'base64')] } + + it 'creates an LFS pointer' do + subject.execute + + blob = repository.blob_at('lfs', new_file_path) + + expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + end + + it "creates an LfsObject with the file's content" do + subject.execute + + expect(LfsObject.last.file.read).to eq raw_file_content + end + end + + it 'links the LfsObject to the project' do + expect do + subject.execute + end.to change { project.lfs_objects.count }.by(1) + end + end + context 'when file status validation is skipped' do let(:action) { 'create' } let(:new_file_path) { 'files/ruby/new_file.rb' } diff --git a/spec/services/lfs/file_transformer_spec.rb b/spec/services/lfs/file_transformer_spec.rb new file mode 100644 index 00000000000..e8938338cb7 --- /dev/null +++ b/spec/services/lfs/file_transformer_spec.rb @@ -0,0 +1,97 @@ +require "spec_helper" + +describe Lfs::FileTransformer do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:file_content) { 'Test file content' } + let(:branch_name) { 'lfs' } + let(:file_path) { 'test_file.lfs' } + + subject { described_class.new(project, branch_name) } + + describe '#new_file' do + context 'with lfs disabled' do + it 'skips gitattributes check' do + expect(repository.raw).not_to receive(:blob_at) + + subject.new_file(file_path, file_content) + end + + it 'returns untransformed content' do + result = subject.new_file(file_path, file_content) + + expect(result.content).to eq(file_content) + end + + it 'returns untransformed encoding' do + result = subject.new_file(file_path, file_content, encoding: 'base64') + + expect(result.encoding).to eq('base64') + end + end + + context 'with lfs enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + it 'reuses cached gitattributes' do + second_file = 'another_file.lfs' + + expect(repository.raw).to receive(:blob_at).with(branch_name, '.gitattributes').once + + subject.new_file(file_path, file_content) + subject.new_file(second_file, file_content) + end + + it "creates an LfsObject with the file's content" do + subject.new_file(file_path, file_content) + + expect(LfsObject.last.file.read).to eq file_content + end + + it 'returns an LFS pointer' do + result = subject.new_file(file_path, file_content) + + expect(result.content).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + end + + it 'returns LFS pointer encoding as text' do + result = subject.new_file(file_path, file_content, encoding: 'base64') + + expect(result.encoding).to eq('text') + end + + context "when doesn't use LFS" do + let(:file_path) { 'other.filetype' } + + it "doesn't create LFS pointers" do + new_content = subject.new_file(file_path, file_content).content + + expect(new_content).not_to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + expect(new_content).to eq(file_content) + end + end + + it 'links LfsObjects to project' do + expect do + subject.new_file(file_path, file_content) + end.to change { project.lfs_objects.count }.by(1) + end + + context 'when LfsObject already exists' do + let(:lfs_pointer) { Gitlab::Git::LfsPointerFile.new(file_content) } + + before do + create(:lfs_object, oid: lfs_pointer.sha256, size: lfs_pointer.size) + end + + it 'links LfsObjects to project' do + expect do + subject.new_file(file_path, file_content) + end.to change { project.lfs_objects.count }.by(1) + end + end + end + end +end diff --git a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb index bb46e1dd9ab..57b6165cfb0 100644 --- a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb +++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb @@ -1,19 +1,39 @@ require 'spec_helper' -describe MergeRequests::MergeRequestDiffCacheService do +describe MergeRequests::MergeRequestDiffCacheService, :use_clean_rails_memory_store_caching do let(:subject) { described_class.new } + let(:merge_request) { create(:merge_request) } describe '#execute' do - it 'retrieves the diff files to cache the highlighted result' do - merge_request = create(:merge_request) - cache_key = [merge_request.merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::FileCollection::MergeRequestDiff.default_options] - - expect(Rails.cache).to receive(:read).with(cache_key).and_return({}) - expect(Rails.cache).to receive(:write).with(cache_key, anything) + before do allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(true) allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(true) + end + + it 'retrieves the diff files to cache the highlighted result' do + new_diff = merge_request.merge_request_diff + cache_key = new_diff.diffs.cache_key + + expect(Rails.cache).to receive(:read).with(cache_key).and_call_original + expect(Rails.cache).to receive(:write).with(cache_key, anything, anything).and_call_original + + subject.execute(merge_request, new_diff) + end + + it 'clears the cache for older diffs on the merge request' do + old_diff = merge_request.merge_request_diff + old_cache_key = old_diff.diffs.cache_key + + subject.execute(merge_request, old_diff) + + new_diff = merge_request.create_merge_request_diff + new_cache_key = new_diff.diffs.cache_key + + expect(Rails.cache).to receive(:delete).with(old_cache_key).and_call_original + expect(Rails.cache).to receive(:read).with(new_cache_key).and_call_original + expect(Rails.cache).to receive(:write).with(new_cache_key, anything, anything).and_call_original - subject.execute(merge_request) + subject.execute(merge_request, new_diff) end end end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 9a44dfde41b..8471467d2fa 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -164,7 +164,7 @@ describe Projects::CreateService, '#execute' do context 'with legacy storage' do before do - gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing") + gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing") end after do @@ -200,7 +200,7 @@ describe Projects::CreateService, '#execute' do end before do - gitlab_shell.add_repository(repository_storage, hashed_path) + gitlab_shell.create_repository(repository_storage, hashed_path) end after do diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 409d5de8d43..d1011b07db6 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -108,7 +108,7 @@ describe Projects::ForkService do let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } before do - gitlab_shell.add_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}") + gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}") end after do diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index ae0e22e3dc0..ce567fe3879 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -151,7 +151,7 @@ describe Projects::TransferService do before do group.add_owner(user) - unless gitlab_shell.add_repository(repository_storage, "#{group.full_path}/#{project.path}") + unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}") raise 'failed to add repository' end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index d454ac0bda5..f3f97b6b921 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -196,7 +196,7 @@ describe Projects::UpdateService do let(:project) { create(:project, :legacy_storage, :repository, creator: user, namespace: user.namespace) } before do - gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing") + gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing") end after do diff --git a/vendor/assets/javascripts/peek.js b/vendor/assets/javascripts/peek.js index 695eeb27c17..7c6d226fa6a 100644 --- a/vendor/assets/javascripts/peek.js +++ b/vendor/assets/javascripts/peek.js @@ -1,14 +1,14 @@ /* - * This is a modified version of https://github.com/peek/peek/blob/master/app/assets/javascripts/peek.js + * 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 queries report specificities + * - 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; + var fetchRequestResults, getRequestId, peekEnabled, updatePerformanceBar, createTable, createTableRow; getRequestId = function() { return $('#peek').data('requestId'); }; @@ -16,39 +16,55 @@ return $('#peek').length; }; updatePerformanceBar = function(results) { - var key, label, data, table, html, tr, duration_td, sql_td, strong; - Object.keys(results.data).forEach(function(key) { Object.keys(results.data[key]).forEach(function(label) { - data = results.data[key][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; + } - if (label == 'queries') { - table = document.createElement('table'); + var table = document.createElement('table'); - for (var i = 0; i < data.length; i += 1) { - tr = document.createElement('tr'); - duration_td = document.createElement('td'); - sql_td = document.createElement('td'); - strong = document.createElement('strong'); + for (var i = 0; i < data.length; i += 1) { + table.appendChild(createTableRow(data[i])); + } - strong.append(data[i]['duration'] + 'ms'); - duration_td.appendChild(strong); - tr.appendChild(duration_td); + table.className = 'table'; - sql_td.appendChild(document.createTextNode(data[i]['sql'])); - tr.appendChild(sql_td); + return table; + }; + createTableRow = function(row) { + var tr = document.createElement('tr'); + var durationTd = document.createElement('td'); + var strong = document.createElement('strong'); - table.appendChild(tr); - } + strong.append(row['duration'] + 'ms'); + durationTd.appendChild(strong); + tr.appendChild(durationTd); - table.className = 'table'; - $("[data-defer-to=" + key + "-" + label + "]").html(table); - } else { - $("[data-defer-to=" + key + "-" + label + "]").text(results.data[key][label]); - } - }); + ['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 $(document).trigger('peek:render', [getRequestId(), results]); + + return tr; }; fetchRequestResults = function() { return $.ajax('/-/peek/results', { diff --git a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml index 33f9efc1490..4223dc18933 100644 --- a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml @@ -285,8 +285,8 @@ production: export CI_APPLICATION_TAG=$CI_COMMIT_SHA export CI_CONTAINER_NAME=ci_job_build_${CI_JOB_ID} export TILLER_NAMESPACE=$KUBE_NAMESPACE - # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Static Code Analysis - export SCA_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') + # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products + export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') function sast_container() { if [[ -n "$CI_REGISTRY_USER" ]]; then @@ -307,11 +307,10 @@ production: } function codeclimate() { - docker run --env CODECLIMATE_CODE="$PWD" \ + docker run --env SOURCE_CODE="$PWD" \ --volume "$PWD":/code \ --volume /var/run/docker.sock:/var/run/docker.sock \ - --volume /tmp/cc:/tmp/cc \ - "registry.gitlab.com/gitlab-org/security-products/codequality/codeclimate:${SCA_VERSION}" analyze -f json > codeclimate.json + "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code } function sast() { @@ -328,7 +327,7 @@ production: --env SAST_DISABLE_REMOTE_CHECKS="${SAST_DISABLE_REMOTE_CHECKS:-false}" \ --volume "$PWD:/code" \ --volume /var/run/docker.sock:/var/run/docker.sock \ - "registry.gitlab.com/gitlab-org/security-products/sast:$SCA_VERSION" /app/bin/run /code + "registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code ;; *) echo "GitLab EE is required" diff --git a/yarn.lock b/yarn.lock index adbb37bea72..3cc5445c402 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6685,9 +6685,9 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" -prettier@1.9.2: - version "1.9.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.9.2.tgz#96bc2132f7a32338e6078aeb29727178c6335827" +prettier@1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.11.1.tgz#61e43fc4cd44e68f2b0dfc2c38cd4bb0fccdcc75" prettier@^1.7.0: version "1.8.2" |