summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock6
-rw-r--r--app/assets/javascripts/awards_handler.js753
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js10
-rw-r--r--app/assets/javascripts/boards/models/list.js3
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js4
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js42
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js11
-rw-r--r--app/assets/javascripts/merge_request_tabs.js8
-rw-r--r--app/assets/javascripts/notes.js2730
-rw-r--r--app/assets/javascripts/prometheus_metrics/constants.js5
-rw-r--r--app/assets/javascripts/prometheus_metrics/index.js6
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js109
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/users_select.js2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss5
-rw-r--r--app/assets/stylesheets/framework/files.scss6
-rw-r--r--app/assets/stylesheets/framework/header.scss1
-rw-r--r--app/assets/stylesheets/framework/layout.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss1
-rw-r--r--app/assets/stylesheets/pages/environments.scss1
-rw-r--r--app/assets/stylesheets/pages/issuable.scss4
-rw-r--r--app/assets/stylesheets/pages/members.scss4
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss2
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss2
-rw-r--r--app/assets/stylesheets/pages/settings.scss63
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/projects/deployments_controller.rb16
-rw-r--r--app/controllers/projects/environments_controller.rb10
-rw-r--r--app/controllers/projects/prometheus_controller.rb24
-rw-r--r--app/finders/issues_finder.rb2
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/users_helper.rb10
-rw-r--r--app/models/deployment.rb11
-rw-r--r--app/models/environment.rb10
-rw-r--r--app/models/project_feature.rb2
-rw-r--r--app/models/project_services/prometheus_service.rb40
-rw-r--r--app/models/user.rb6
-rw-r--r--app/views/groups/merge_requests.html.haml3
-rw-r--r--app/views/layouts/header/_new_dropdown.haml4
-rw-r--r--app/views/profiles/show.html.haml107
-rw-r--r--app/views/projects/commit/show.html.haml11
-rw-r--r--app/views/projects/deployments/_deployment.html.haml17
-rw-r--r--app/views/projects/edit.html.haml66
-rw-r--r--app/views/projects/hooks/_index.html.haml4
-rw-r--r--app/views/projects/pipelines_settings/_badge.html.haml4
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml4
-rw-r--r--app/views/projects/project_members/_index.html.haml4
-rw-r--r--app/views/projects/services/_form.html.haml4
-rw-r--r--app/views/projects/services/_index.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml45
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml1
-rw-r--r--app/views/projects/settings/integrations/show.html.haml1
-rw-r--r--app/views/projects/settings/members/show.html.haml2
-rw-r--r--app/views/projects/snippets/show.html.haml3
-rw-r--r--app/views/projects/tree/_readme.html.haml2
-rw-r--r--app/views/projects/triggers/_index.html.haml4
-rw-r--r--app/views/projects/variables/_index.html.haml4
-rw-r--r--app/views/shared/icons/_icon_empty_metrics.svg5
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml2
-rw-r--r--app/views/shared/snippets/_header.html.haml2
-rw-r--r--app/views/snippets/show.html.haml3
-rw-r--r--changelogs/unreleased/28717-support-additional-prometheus-metrics.yml4
-rw-r--r--changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml4
-rw-r--r--changelogs/unreleased/34010-fix-linking-to-parallel-diff-line-number-creating-gray-box.yml4
-rw-r--r--changelogs/unreleased/commit-comments-limited-width.yml4
-rw-r--r--changelogs/unreleased/dm-unnecessary-top-padding.yml4
-rw-r--r--changelogs/unreleased/doc-gitaly-network.yml4
-rw-r--r--changelogs/unreleased/mk-fix-breadcrumb-order-33938.yml4
-rw-r--r--changelogs/unreleased/mk-fix-issue-34068.yml4
-rw-r--r--changelogs/unreleased/project-readme-limited-width.yml4
-rw-r--r--changelogs/unreleased/tc-refactor-projects-finder-init-collection.yml4
-rw-r--r--config/boot.rb12
-rw-r--r--config/initializers/active_record_data_types.rb73
-rw-r--r--config/initializers/active_record_table_definition.rb22
-rw-r--r--config/initializers/bootstrap_form.rb7
-rw-r--r--config/initializers/flipper.rb4
-rw-r--r--config/prometheus/additional_metrics.yml32
-rw-r--r--config/routes/project.rb6
-rw-r--r--config/webpack.config.js5
-rw-r--r--db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb2
-rw-r--r--db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb2
-rw-r--r--db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb2
-rw-r--r--db/migrate/20160919144305_add_type_to_labels.rb2
-rw-r--r--db/migrate/20161018124658_make_project_owners_masters.rb2
-rw-r--r--db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb2
-rw-r--r--db/post_migrate/20170309171644_reset_relative_position_for_issue.rb3
-rw-r--r--db/post_migrate/20170317162059_update_upload_paths_to_system.rb2
-rw-r--r--db/post_migrate/20170406142253_migrate_user_project_view.rb2
-rw-r--r--db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb2
-rw-r--r--doc/administration/gitaly/index.md139
-rw-r--r--lib/feature.rb2
-rw-r--r--lib/gitlab/database/migration_helpers.rb6
-rw-r--r--lib/gitlab/git/diff.rb2
-rw-r--r--lib/gitlab/gitaly_client/diff_stitcher.rb5
-rw-r--r--lib/gitlab/prometheus/additional_metrics_parser.rb34
-rw-r--r--lib/gitlab/prometheus/metric.rb16
-rw-r--r--lib/gitlab/prometheus/metric_group.rb14
-rw-r--r--lib/gitlab/prometheus/parsing_error.rb5
-rw-r--r--lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb22
-rw-r--r--lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb22
-rw-r--r--lib/gitlab/prometheus/queries/base_query.rb2
-rw-r--r--lib/gitlab/prometheus/queries/deployment_query.rb43
-rw-r--r--lib/gitlab/prometheus/queries/environment_query.rb35
-rw-r--r--lib/gitlab/prometheus/queries/matched_metrics_query.rb80
-rw-r--r--lib/gitlab/prometheus/queries/query_additional_metrics.rb73
-rw-r--r--lib/gitlab/prometheus_client.rb8
-rw-r--r--lib/gitlab/visibility_level.rb2
-rw-r--r--qa/Dockerfile12
-rw-r--r--qa/qa/specs/config.rb2
-rw-r--r--spec/controllers/application_controller_spec.rb30
-rw-r--r--spec/controllers/projects/deployments_controller_spec.rb64
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb42
-rw-r--r--spec/controllers/projects/prometheus_controller_spec.rb59
-rw-r--r--spec/factories/services.rb8
-rw-r--r--spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json58
-rw-r--r--spec/helpers/groups_helper_spec.rb13
-rw-r--r--spec/javascripts/boards/board_new_issue_spec.js203
-rw-r--r--spec/javascripts/boards/list_spec.js37
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js58
-rw-r--r--spec/javascripts/fixtures/merge_requests.rb13
-rw-r--r--spec/javascripts/fixtures/prometheus_service.rb30
-rw-r--r--spec/javascripts/groups/groups_spec.js24
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js85
-rw-r--r--spec/javascripts/merge_request_notes_spec.js2
-rw-r--r--spec/javascripts/merge_request_tabs_spec.js138
-rw-r--r--spec/javascripts/prometheus_metrics/mock_data.js41
-rw-r--r--spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js158
-rw-r--r--spec/javascripts/test_bundle.js17
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb66
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb2
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb2
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb2
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb2
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb8
-rw-r--r--spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb246
-rw-r--r--spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb25
-rw-r--r--spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb21
-rw-r--r--spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb134
-rw-r--r--spec/lib/gitlab/prometheus_client_spec.rb30
-rw-r--r--spec/lib/gitlab/visibility_level_spec.rb6
-rw-r--r--spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb2
-rw-r--r--spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb2
-rw-r--r--spec/migrations/migrate_user_project_view_spec.rb2
-rw-r--r--spec/models/deployment_spec.rb32
-rw-r--r--spec/models/environment_spec.rb93
-rw-r--r--spec/models/project_services/prometheus_service_spec.rb5
-rw-r--r--spec/models/user_spec.rb14
-rw-r--r--spec/support/api/schema_matcher.rb14
-rw-r--r--spec/support/prometheus/additional_metrics_shared_examples.rb101
-rw-r--r--spec/support/prometheus/metric_builders.rb27
-rw-r--r--spec/support/prometheus_helpers.rb59
-rw-r--r--spec/views/projects/commit/show.html.haml_spec.rb8
153 files changed, 4707 insertions, 2251 deletions
diff --git a/Gemfile b/Gemfile
index b1790b23dcd..6c53bfe6efe 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,7 +2,6 @@ source 'https://rubygems.org'
gem 'rails', '4.2.8'
gem 'rails-deprecated_sanitizer', '~> 1.0.3'
-gem 'bootsnap', '~> 1.0.0'
# Responders respond_to and respond_with
gem 'responders', '~> 2.0'
@@ -123,6 +122,7 @@ gem 'asciidoctor', '~> 1.5.2'
gem 'asciidoctor-plantuml', '0.0.7'
gem 'rouge', '~> 2.0'
gem 'truncato', '~> 0.7.8'
+gem 'bootstrap_form', '~> 2.7.0'
# See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s
# and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM
diff --git a/Gemfile.lock b/Gemfile.lock
index bfd0498db35..4715363c5b8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -83,11 +83,10 @@ GEM
bindata (2.3.5)
binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1)
- bootsnap (1.0.0)
- msgpack (~> 1.0)
bootstrap-sass (3.3.6)
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
+ bootstrap_form (2.7.0)
brakeman (3.6.1)
browser (2.2.0)
builder (3.2.3)
@@ -464,7 +463,6 @@ GEM
minitest (5.7.0)
mmap2 (2.2.6)
mousetrap-rails (1.4.6)
- msgpack (1.1.0)
multi_json (1.12.1)
multi_xml (0.6.0)
multipart-post (2.0.0)
@@ -928,8 +926,8 @@ DEPENDENCIES
benchmark-ips (~> 2.3.0)
better_errors (~> 2.1.0)
binding_of_caller (~> 0.7.2)
- bootsnap (~> 1.0.0)
bootstrap-sass (~> 3.3.0)
+ bootstrap_form (~> 2.7.0)
brakeman (~> 3.6.0)
browser (~> 2.2)
bullet (~> 5.5.0)
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index adb45b0606d..ebe722061d7 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,3 +1,4 @@
+/* eslint-disable class-methods-use-this */
/* global Flash */
import Cookies from 'js-cookie';
@@ -68,141 +69,140 @@ function renderCategory(name, emojiList, opts = {}) {
`;
}
-function AwardsHandler() {
- this.eventListeners = [];
- this.aliases = emojiAliases;
- // If the user shows intent let's pre-build the menu
- this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
- const $menu = $('.emoji-menu');
- if ($menu.length === 0) {
- requestAnimationFrame(() => {
- this.createEmojiMenu();
- });
- }
- // Prebuild the categoryMap
- categoryMap = categoryMap || buildCategoryMap();
- });
- this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
- e.stopPropagation();
- e.preventDefault();
- this.showEmojiMenu($(e.currentTarget));
- });
+export default class AwardsHandler {
+ constructor() {
+ this.eventListeners = [];
+ this.aliases = emojiAliases;
+ // If the user shows intent let's pre-build the menu
+ this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
+ const $menu = $('.emoji-menu');
+ if ($menu.length === 0) {
+ requestAnimationFrame(() => {
+ this.createEmojiMenu();
+ });
+ }
+ // Prebuild the categoryMap
+ categoryMap = categoryMap || buildCategoryMap();
+ });
+ this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ this.showEmojiMenu($(e.currentTarget));
+ });
- this.registerEventListener('on', $('html'), 'click', (e) => {
- const $target = $(e.target);
- if (!$target.closest('.emoji-menu-content').length) {
- $('.js-awards-block.current').removeClass('current');
- }
- if (!$target.closest('.emoji-menu').length) {
- if ($('.emoji-menu').is(':visible')) {
- $('.js-add-award.is-active').removeClass('is-active');
- $('.emoji-menu').removeClass('is-visible');
+ this.registerEventListener('on', $('html'), 'click', (e) => {
+ const $target = $(e.target);
+ if (!$target.closest('.emoji-menu-content').length) {
+ $('.js-awards-block.current').removeClass('current');
}
- }
- });
- this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
- e.preventDefault();
- const $target = $(e.currentTarget);
- const $glEmojiElement = $target.find('gl-emoji');
- const $spriteIconElement = $target.find('.icon');
- const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
-
- $target.closest('.js-awards-block').addClass('current');
- this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji);
- });
-}
+ if (!$target.closest('.emoji-menu').length) {
+ if ($('.emoji-menu').is(':visible')) {
+ $('.js-add-award.is-active').removeClass('is-active');
+ $('.emoji-menu').removeClass('is-visible');
+ }
+ }
+ });
+ this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
+ e.preventDefault();
+ const $target = $(e.currentTarget);
+ const $glEmojiElement = $target.find('gl-emoji');
+ const $spriteIconElement = $target.find('.icon');
+ const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
+
+ $target.closest('.js-awards-block').addClass('current');
+ this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji);
+ });
+ }
-AwardsHandler.prototype.registerEventListener = function registerEventListener(method = 'on', element, ...args) {
- element[method].call(element, ...args);
- this.eventListeners.push({
- element,
- args,
- });
-};
+ registerEventListener(method = 'on', element, ...args) {
+ element[method].call(element, ...args);
+ this.eventListeners.push({
+ element,
+ args,
+ });
+ }
-AwardsHandler.prototype.showEmojiMenu = function showEmojiMenu($addBtn) {
- if ($addBtn.hasClass('js-note-emoji')) {
- $addBtn.closest('.note').find('.js-awards-block').addClass('current');
- } else {
- $addBtn.closest('.js-awards-block').addClass('current');
- }
-
- const $menu = $('.emoji-menu');
- const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
- const $userAuthored = this.isUserAuthored($addBtn);
- if ($menu.length) {
- if ($menu.is('.is-visible')) {
- $addBtn.removeClass('is-active');
- $menu.removeClass('is-visible');
- $('.js-emoji-menu-search').blur();
+ showEmojiMenu($addBtn) {
+ if ($addBtn.hasClass('js-note-emoji')) {
+ $addBtn.closest('.note').find('.js-awards-block').addClass('current');
} else {
- $addBtn.addClass('is-active');
- this.positionMenu($menu, $addBtn);
- $menu.addClass('is-visible');
- $('.js-emoji-menu-search').focus();
+ $addBtn.closest('.js-awards-block').addClass('current');
}
- } else {
- $addBtn.addClass('is-loading is-active');
- this.createEmojiMenu(() => {
- const $createdMenu = $('.emoji-menu');
- $addBtn.removeClass('is-loading');
- this.positionMenu($createdMenu, $addBtn);
- return setTimeout(() => {
- $createdMenu.addClass('is-visible');
+
+ const $menu = $('.emoji-menu');
+ const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
+ const $userAuthored = this.isUserAuthored($addBtn);
+ if ($menu.length) {
+ if ($menu.is('.is-visible')) {
+ $addBtn.removeClass('is-active');
+ $menu.removeClass('is-visible');
+ $('.js-emoji-menu-search').blur();
+ } else {
+ $addBtn.addClass('is-active');
+ this.positionMenu($menu, $addBtn);
+ $menu.addClass('is-visible');
$('.js-emoji-menu-search').focus();
- }, 200);
- });
+ }
+ } else {
+ $addBtn.addClass('is-loading is-active');
+ this.createEmojiMenu(() => {
+ const $createdMenu = $('.emoji-menu');
+ $addBtn.removeClass('is-loading');
+ this.positionMenu($createdMenu, $addBtn);
+ return setTimeout(() => {
+ $createdMenu.addClass('is-visible');
+ $('.js-emoji-menu-search').focus();
+ }, 200);
+ });
+ }
+
+ $thumbsBtn.toggleClass('disabled', $userAuthored);
}
- $thumbsBtn.toggleClass('disabled', $userAuthored);
-};
+ // Create the emoji menu with the first category of emojis.
+ // Then render the remaining categories of emojis one by one to avoid jank.
+ createEmojiMenu(callback) {
+ if (this.isCreatingEmojiMenu) {
+ return;
+ }
+ this.isCreatingEmojiMenu = true;
-// Create the emoji menu with the first category of emojis.
-// Then render the remaining categories of emojis one by one to avoid jank.
-AwardsHandler.prototype.createEmojiMenu = function createEmojiMenu(callback) {
- if (this.isCreatingEmojiMenu) {
- return;
- }
- this.isCreatingEmojiMenu = true;
-
- // Render the first category
- categoryMap = categoryMap || buildCategoryMap();
- const categoryNameKey = Object.keys(categoryMap)[0];
- const emojisInCategory = categoryMap[categoryNameKey];
- const firstCategory = renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
-
- // Render the frequently used
- const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
- let frequentlyUsedCatgegory = '';
- if (frequentlyUsedEmojis.length > 0) {
- frequentlyUsedCatgegory = renderCategory('Frequently used', frequentlyUsedEmojis, {
- menuListClass: 'frequent-emojis',
- });
- }
+ // Render the first category
+ categoryMap = categoryMap || buildCategoryMap();
+ const categoryNameKey = Object.keys(categoryMap)[0];
+ const emojisInCategory = categoryMap[categoryNameKey];
+ const firstCategory = renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
+
+ // Render the frequently used
+ const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
+ let frequentlyUsedCatgegory = '';
+ if (frequentlyUsedEmojis.length > 0) {
+ frequentlyUsedCatgegory = renderCategory('Frequently used', frequentlyUsedEmojis, {
+ menuListClass: 'frequent-emojis',
+ });
+ }
- const emojiMenuMarkup = `
- <div class="emoji-menu">
- <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
+ const emojiMenuMarkup = `
+ <div class="emoji-menu">
+ <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
- <div class="emoji-menu-content">
- ${frequentlyUsedCatgegory}
- ${firstCategory}
+ <div class="emoji-menu-content">
+ ${frequentlyUsedCatgegory}
+ ${firstCategory}
+ </div>
</div>
- </div>
- `;
+ `;
- document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
+ document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
- this.addRemainingEmojiMenuCategories();
- this.setupSearch();
- if (callback) {
- callback();
+ this.addRemainingEmojiMenuCategories();
+ this.setupSearch();
+ if (callback) {
+ callback();
+ }
}
-};
-AwardsHandler
- .prototype
- .addRemainingEmojiMenuCategories = function addRemainingEmojiMenuCategories() {
+ addRemainingEmojiMenuCategories() {
if (this.isAddingRemainingEmojiMenuCategories) {
return;
}
@@ -243,176 +243,174 @@ AwardsHandler
emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>');
throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`);
});
- };
-
-AwardsHandler.prototype.positionMenu = function positionMenu($menu, $addBtn) {
- const position = $addBtn.data('position');
- // The menu could potentially be off-screen or in a hidden overflow element
- // So we position the element absolute in the body
- const css = {
- top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
- };
- if (position === 'right') {
- css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
- $menu.addClass('is-aligned-right');
- } else {
- css.left = `${$addBtn.offset().left}px`;
- $menu.removeClass('is-aligned-right');
- }
- return $menu.css(css);
-};
-
-AwardsHandler.prototype.addAward = function addAward(
- votesBlock,
- awardUrl,
- emoji,
- checkMutuality,
- callback,
-) {
- const normalizedEmoji = this.normalizeEmojiName(emoji);
- const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
- this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
- this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
- return typeof callback === 'function' ? callback() : undefined;
- });
- $('.emoji-menu').removeClass('is-visible');
- $('.js-add-award.is-active').removeClass('is-active');
-};
+ }
-AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
- votesBlock,
- emoji,
- checkForMutuality,
-) {
- if (checkForMutuality || checkForMutuality === null) {
- this.checkMutuality(votesBlock, emoji);
- }
- this.addEmojiToFrequentlyUsedList(emoji);
- const normalizedEmoji = this.normalizeEmojiName(emoji);
- const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
- if ($emojiButton.length > 0) {
- if (this.isActive($emojiButton)) {
- this.decrementCounter($emojiButton, normalizedEmoji);
+ positionMenu($menu, $addBtn) {
+ const position = $addBtn.data('position');
+ // The menu could potentially be off-screen or in a hidden overflow element
+ // So we position the element absolute in the body
+ const css = {
+ top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
+ };
+ if (position === 'right') {
+ css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
+ $menu.addClass('is-aligned-right');
} else {
- const counter = $emojiButton.find('.js-counter');
- counter.text(parseInt(counter.text(), 10) + 1);
- $emojiButton.addClass('active');
- this.addYouToUserList(votesBlock, normalizedEmoji);
- this.animateEmoji($emojiButton);
+ css.left = `${$addBtn.offset().left}px`;
+ $menu.removeClass('is-aligned-right');
}
- } else {
- votesBlock.removeClass('hidden');
- this.createEmoji(votesBlock, normalizedEmoji);
+ return $menu.css(css);
}
-};
-AwardsHandler.prototype.getVotesBlock = function getVotesBlock() {
- const currentBlock = $('.js-awards-block.current');
- let resultantVotesBlock = currentBlock;
- if (currentBlock.length === 0) {
- resultantVotesBlock = $('.js-awards-block').eq(0);
+ addAward(
+ votesBlock,
+ awardUrl,
+ emoji,
+ checkMutuality,
+ callback,
+ ) {
+ const normalizedEmoji = this.normalizeEmojiName(emoji);
+ const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+ this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
+ this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
+ return typeof callback === 'function' ? callback() : undefined;
+ });
+ $('.emoji-menu').removeClass('is-visible');
+ $('.js-add-award.is-active').removeClass('is-active');
}
- return resultantVotesBlock;
-};
+ addAwardToEmojiBar(
+ votesBlock,
+ emoji,
+ checkForMutuality,
+ ) {
+ if (checkForMutuality || checkForMutuality === null) {
+ this.checkMutuality(votesBlock, emoji);
+ }
+ this.addEmojiToFrequentlyUsedList(emoji);
+ const normalizedEmoji = this.normalizeEmojiName(emoji);
+ const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+ if ($emojiButton.length > 0) {
+ if (this.isActive($emojiButton)) {
+ this.decrementCounter($emojiButton, normalizedEmoji);
+ } else {
+ const counter = $emojiButton.find('.js-counter');
+ counter.text(parseInt(counter.text(), 10) + 1);
+ $emojiButton.addClass('active');
+ this.addYouToUserList(votesBlock, normalizedEmoji);
+ this.animateEmoji($emojiButton);
+ }
+ } else {
+ votesBlock.removeClass('hidden');
+ this.createEmoji(votesBlock, normalizedEmoji);
+ }
+ }
-AwardsHandler.prototype.getAwardUrl = function getAwardUrl() {
- return this.getVotesBlock().data('award-url');
-};
+ getVotesBlock() {
+ const currentBlock = $('.js-awards-block.current');
+ let resultantVotesBlock = currentBlock;
+ if (currentBlock.length === 0) {
+ resultantVotesBlock = $('.js-awards-block').eq(0);
+ }
+
+ return resultantVotesBlock;
+ }
-AwardsHandler.prototype.checkMutuality = function checkMutuality(votesBlock, emoji) {
- const awardUrl = this.getAwardUrl();
- if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
- const mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup';
- const $emojiButton = votesBlock.find(`[data-name="${mutualVote}"]`).parent();
- const isAlreadyVoted = $emojiButton.hasClass('active');
- if (isAlreadyVoted) {
- this.addAward(votesBlock, awardUrl, mutualVote, false);
+ getAwardUrl() {
+ return this.getVotesBlock().data('award-url');
+ }
+
+ checkMutuality(votesBlock, emoji) {
+ const awardUrl = this.getAwardUrl();
+ if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
+ const mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup';
+ const $emojiButton = votesBlock.find(`[data-name="${mutualVote}"]`).parent();
+ const isAlreadyVoted = $emojiButton.hasClass('active');
+ if (isAlreadyVoted) {
+ this.addAward(votesBlock, awardUrl, mutualVote, false);
+ }
}
}
-};
-AwardsHandler.prototype.isActive = function isActive($emojiButton) {
- return $emojiButton.hasClass('active');
-};
+ isActive($emojiButton) {
+ return $emojiButton.hasClass('active');
+ }
-AwardsHandler.prototype.isUserAuthored = function isUserAuthored($button) {
- return $button.hasClass('js-user-authored');
-};
+ isUserAuthored($button) {
+ return $button.hasClass('js-user-authored');
+ }
-AwardsHandler.prototype.decrementCounter = function decrementCounter($emojiButton, emoji) {
- const counter = $('.js-counter', $emojiButton);
- const counterNumber = parseInt(counter.text(), 10);
- if (counterNumber > 1) {
- counter.text(counterNumber - 1);
- this.removeYouFromUserList($emojiButton);
- } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
- $emojiButton.tooltip('destroy');
- counter.text('0');
- this.removeYouFromUserList($emojiButton);
- if ($emojiButton.parents('.note').length) {
+ decrementCounter($emojiButton, emoji) {
+ const counter = $('.js-counter', $emojiButton);
+ const counterNumber = parseInt(counter.text(), 10);
+ if (counterNumber > 1) {
+ counter.text(counterNumber - 1);
+ this.removeYouFromUserList($emojiButton);
+ } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
+ $emojiButton.tooltip('destroy');
+ counter.text('0');
+ this.removeYouFromUserList($emojiButton);
+ if ($emojiButton.parents('.note').length) {
+ this.removeEmoji($emojiButton);
+ }
+ } else {
this.removeEmoji($emojiButton);
}
- } else {
- this.removeEmoji($emojiButton);
+ return $emojiButton.removeClass('active');
}
- return $emojiButton.removeClass('active');
-};
-AwardsHandler.prototype.removeEmoji = function removeEmoji($emojiButton) {
- $emojiButton.tooltip('destroy');
- $emojiButton.remove();
- const $votesBlock = this.getVotesBlock();
- if ($votesBlock.find('.js-emoji-btn').length === 0) {
- $votesBlock.addClass('hidden');
+ removeEmoji($emojiButton) {
+ $emojiButton.tooltip('destroy');
+ $emojiButton.remove();
+ const $votesBlock = this.getVotesBlock();
+ if ($votesBlock.find('.js-emoji-btn').length === 0) {
+ $votesBlock.addClass('hidden');
+ }
}
-};
-
-AwardsHandler.prototype.getAwardTooltip = function getAwardTooltip($awardBlock) {
- return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
-};
-AwardsHandler.prototype.toSentence = function toSentence(list) {
- let sentence;
- if (list.length <= 2) {
- sentence = list.join(' and ');
- } else {
- sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
+ getAwardTooltip($awardBlock) {
+ return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
}
- return sentence;
-};
+ toSentence(list) {
+ let sentence;
+ if (list.length <= 2) {
+ sentence = list.join(' and ');
+ } else {
+ sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
+ }
-AwardsHandler.prototype.removeYouFromUserList = function removeYouFromUserList($emojiButton) {
- const awardBlock = $emojiButton;
- const originalTitle = this.getAwardTooltip(awardBlock);
- const authors = originalTitle.split(FROM_SENTENCE_REGEX);
- authors.splice(authors.indexOf('You'), 1);
- return awardBlock
- .closest('.js-emoji-btn')
- .removeData('title')
- .removeAttr('data-title')
- .removeAttr('data-original-title')
- .attr('title', this.toSentence(authors))
- .tooltip('fixTitle');
-};
+ return sentence;
+ }
-AwardsHandler.prototype.addYouToUserList = function addYouToUserList(votesBlock, emoji) {
- const awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
- const origTitle = this.getAwardTooltip(awardBlock);
- let users = [];
- if (origTitle) {
- users = origTitle.trim().split(FROM_SENTENCE_REGEX);
- }
- users.unshift('You');
- return awardBlock
- .attr('title', this.toSentence(users))
- .tooltip('fixTitle');
-};
+ removeYouFromUserList($emojiButton) {
+ const awardBlock = $emojiButton;
+ const originalTitle = this.getAwardTooltip(awardBlock);
+ const authors = originalTitle.split(FROM_SENTENCE_REGEX);
+ authors.splice(authors.indexOf('You'), 1);
+ return awardBlock
+ .closest('.js-emoji-btn')
+ .removeData('title')
+ .removeAttr('data-title')
+ .removeAttr('data-original-title')
+ .attr('title', this.toSentence(authors))
+ .tooltip('fixTitle');
+ }
+
+ addYouToUserList(votesBlock, emoji) {
+ const awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
+ const origTitle = this.getAwardTooltip(awardBlock);
+ let users = [];
+ if (origTitle) {
+ users = origTitle.trim().split(FROM_SENTENCE_REGEX);
+ }
+ users.unshift('You');
+ return awardBlock
+ .attr('title', this.toSentence(users))
+ .tooltip('fixTitle');
+ }
-AwardsHandler
- .prototype
- .createAwardButtonForVotesBlock = function createAwardButtonForVotesBlock(votesBlock, emojiName) {
+ createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
<button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
${glEmojiTag(emojiName)}
@@ -424,144 +422,141 @@ AwardsHandler
this.animateEmoji($emojiButton);
$('.award-control').tooltip();
votesBlock.removeClass('current');
- };
+ }
-AwardsHandler.prototype.animateEmoji = function animateEmoji($emoji) {
- const className = 'pulse animated once short';
- $emoji.addClass(className);
+ animateEmoji($emoji) {
+ const className = 'pulse animated once short';
+ $emoji.addClass(className);
- this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
- $(e.currentTarget).removeClass(className);
- });
-};
+ this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
+ $(e.currentTarget).removeClass(className);
+ });
+ }
-AwardsHandler.prototype.createEmoji = function createEmoji(votesBlock, emoji) {
- if ($('.emoji-menu').length) {
- this.createAwardButtonForVotesBlock(votesBlock, emoji);
+ createEmoji(votesBlock, emoji) {
+ if ($('.emoji-menu').length) {
+ this.createAwardButtonForVotesBlock(votesBlock, emoji);
+ }
+ this.createEmojiMenu(() => {
+ this.createAwardButtonForVotesBlock(votesBlock, emoji);
+ });
}
- this.createEmojiMenu(() => {
- this.createAwardButtonForVotesBlock(votesBlock, emoji);
- });
-};
-AwardsHandler.prototype.postEmoji = function postEmoji($emojiButton, awardUrl, emoji, callback) {
- if (this.isUserAuthored($emojiButton)) {
- this.userAuthored($emojiButton);
- } else {
- $.post(awardUrl, {
- name: emoji,
- }, (data) => {
- if (data.ok) {
- callback();
- }
- }).fail(() => new Flash('Something went wrong on our end.'));
+ postEmoji($emojiButton, awardUrl, emoji, callback) {
+ if (this.isUserAuthored($emojiButton)) {
+ this.userAuthored($emojiButton);
+ } else {
+ $.post(awardUrl, {
+ name: emoji,
+ }, (data) => {
+ if (data.ok) {
+ callback();
+ }
+ }).fail(() => new Flash('Something went wrong on our end.'));
+ }
}
-};
-AwardsHandler.prototype.findEmojiIcon = function findEmojiIcon(votesBlock, emoji) {
- return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
-};
+ findEmojiIcon(votesBlock, emoji) {
+ return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
+ }
-AwardsHandler.prototype.userAuthored = function userAuthored($emojiButton) {
- const oldTitle = this.getAwardTooltip($emojiButton);
- const newTitle = 'You cannot vote on your own issue, MR and note';
- gl.utils.updateTooltipTitle($emojiButton, newTitle).tooltip('show');
- // Restore tooltip back to award list
- return setTimeout(() => {
- $emojiButton.tooltip('hide');
- gl.utils.updateTooltipTitle($emojiButton, oldTitle);
- }, 2800);
-};
+ userAuthored($emojiButton) {
+ const oldTitle = this.getAwardTooltip($emojiButton);
+ const newTitle = 'You cannot vote on your own issue, MR and note';
+ gl.utils.updateTooltipTitle($emojiButton, newTitle).tooltip('show');
+ // Restore tooltip back to award list
+ return setTimeout(() => {
+ $emojiButton.tooltip('hide');
+ gl.utils.updateTooltipTitle($emojiButton, oldTitle);
+ }, 2800);
+ }
-AwardsHandler.prototype.scrollToAwards = function scrollToAwards() {
- const options = {
- scrollTop: $('.awards').offset().top - 110,
- };
- return $('body, html').animate(options, 200);
-};
+ scrollToAwards() {
+ const options = {
+ scrollTop: $('.awards').offset().top - 110,
+ };
+ return $('body, html').animate(options, 200);
+ }
-AwardsHandler.prototype.normalizeEmojiName = function normalizeEmojiName(emoji) {
- return Object.prototype.hasOwnProperty.call(this.aliases, emoji) ? this.aliases[emoji] : emoji;
-};
+ normalizeEmojiName(emoji) {
+ return Object.prototype.hasOwnProperty.call(this.aliases, emoji) ? this.aliases[emoji] : emoji;
+ }
-AwardsHandler
- .prototype
- .addEmojiToFrequentlyUsedList = function addEmojiToFrequentlyUsedList(emoji) {
+ addEmojiToFrequentlyUsedList(emoji) {
if (isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
- };
-
-AwardsHandler.prototype.getFrequentlyUsedEmojis = function getFrequentlyUsedEmojis() {
- return this.frequentlyUsedEmojis || (() => {
- const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
- this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
- inputName => isEmojiNameValid(inputName),
- );
+ }
- return this.frequentlyUsedEmojis;
- })();
-};
+ getFrequentlyUsedEmojis() {
+ return this.frequentlyUsedEmojis || (() => {
+ const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
+ this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
+ inputName => isEmojiNameValid(inputName),
+ );
-AwardsHandler.prototype.setupSearch = function setupSearch() {
- const $search = $('.js-emoji-menu-search');
+ return this.frequentlyUsedEmojis;
+ })();
+ }
- this.registerEventListener('on', $search, 'input', (e) => {
- const term = $(e.target).val().trim();
- this.searchEmojis(term);
- });
+ setupSearch() {
+ const $search = $('.js-emoji-menu-search');
- const $menu = $('.emoji-menu');
- this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
- if (e.target === e.currentTarget) {
- // Clear the search
- this.searchEmojis('');
- }
- });
-};
+ this.registerEventListener('on', $search, 'input', (e) => {
+ const term = $(e.target).val().trim();
+ this.searchEmojis(term);
+ });
-AwardsHandler.prototype.searchEmojis = function searchEmojis(term) {
- const $search = $('.js-emoji-menu-search');
- $search.val(term);
-
- // Clean previous search results
- $('ul.emoji-menu-search, h5.emoji-search-title').remove();
- if (term.length > 0) {
- // Generate a search result block
- const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
- const foundEmojis = this.findMatchingEmojiElements(term).show();
- const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
- $('.emoji-menu-content ul, .emoji-menu-content h5').hide();
- $('.emoji-menu-content').append(h5).append(ul);
- } else {
- $('.emoji-menu-content').children().show();
+ const $menu = $('.emoji-menu');
+ this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
+ if (e.target === e.currentTarget) {
+ // Clear the search
+ this.searchEmojis('');
+ }
+ });
}
-};
-
-AwardsHandler.prototype.findMatchingEmojiElements = function findMatchingEmojiElements(term) {
- const safeTerm = term.toLowerCase();
- const namesMatchingAlias = [];
- Object.keys(emojiAliases).forEach((alias) => {
- if (alias.indexOf(safeTerm) >= 0) {
- namesMatchingAlias.push(emojiAliases[alias]);
+ searchEmojis(term) {
+ const $search = $('.js-emoji-menu-search');
+ $search.val(term);
+
+ // Clean previous search results
+ $('ul.emoji-menu-search, h5.emoji-search-title').remove();
+ if (term.length > 0) {
+ // Generate a search result block
+ const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
+ const foundEmojis = this.findMatchingEmojiElements(term).show();
+ const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
+ $('.emoji-menu-content ul, .emoji-menu-content h5').hide();
+ $('.emoji-menu-content').append(h5).append(ul);
+ } else {
+ $('.emoji-menu-content').children().show();
}
- });
- const $matchingElements = namesMatchingAlias.concat(safeTerm)
- .reduce(
- ($result, searchTerm) =>
- $result.add($(`.emoji-menu-list:not(.frequent-emojis) [data-name*="${searchTerm}"]`)),
- $([]),
- );
- return $matchingElements.closest('li').clone();
-};
+ }
-AwardsHandler.prototype.destroy = function destroy() {
- this.eventListeners.forEach((entry) => {
- entry.element.off.call(entry.element, ...entry.args);
- });
- $('.emoji-menu').remove();
-};
+ findMatchingEmojiElements(term) {
+ const safeTerm = term.toLowerCase();
+
+ const namesMatchingAlias = [];
+ Object.keys(emojiAliases).forEach((alias) => {
+ if (alias.indexOf(safeTerm) >= 0) {
+ namesMatchingAlias.push(emojiAliases[alias]);
+ }
+ });
+ const $matchingElements = namesMatchingAlias.concat(safeTerm)
+ .reduce(
+ ($result, searchTerm) =>
+ $result.add($(`.emoji-menu-list:not(.frequent-emojis) [data-name*="${searchTerm}"]`)),
+ $([]),
+ );
+ return $matchingElements.closest('li').clone();
+ }
-export default AwardsHandler;
+ destroy() {
+ this.eventListeners.forEach((entry) => {
+ entry.element.off.call(entry.element, ...entry.args);
+ });
+ $('.emoji-menu').remove();
+ }
+}
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index b1c47b09c35..4af8b0c7713 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -17,7 +17,7 @@ export default {
methods: {
submit(e) {
e.preventDefault();
- if (this.title.trim() === '') return;
+ if (this.title.trim() === '') return Promise.resolve();
this.error = false;
@@ -29,7 +29,10 @@ export default {
assignees: [],
});
- this.list.newIssue(issue)
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
+ this.cancel();
+
+ return this.list.newIssue(issue)
.then(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
@@ -47,9 +50,6 @@ export default {
// Show error message
this.error = true;
});
-
- eventHub.$emit(`scroll-board-list-${this.list.id}`);
- this.cancel();
},
cancel() {
this.title = '';
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 548de1a4c52..b4b09b3876e 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -112,8 +112,7 @@ class List {
.then((resp) => {
const data = resp.json();
issue.id = data.iid;
- })
- .then(() => {
+
if (this.issuesSize > 1) {
const moveBeforeIid = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index c7c8d42e677..1425769d2de 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -40,6 +40,10 @@ class FilteredSearchManager {
return [];
})
.then((searches) => {
+ if (!searches) {
+ return;
+ }
+
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
index f6dc4290fd5..6eab6083e8f 100644
--- a/app/assets/javascripts/groups/stores/groups_store.js
+++ b/app/assets/javascripts/groups/stores/groups_store.js
@@ -47,8 +47,8 @@ export default class GroupsStore {
// Map groups to an object
groups.map((group) => {
- mappedGroups[group.id] = group;
- mappedGroups[group.id].subGroups = {};
+ mappedGroups[`id${group.id}`] = group;
+ mappedGroups[`id${group.id}`].subGroups = {};
return group;
});
@@ -56,26 +56,27 @@ export default class GroupsStore {
const currentGroup = mappedGroups[key];
if (currentGroup.parentId) {
// If the group is not at the root level, add it to its parent array of subGroups.
- const findParentGroup = mappedGroups[currentGroup.parentId];
+ const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
if (findParentGroup) {
- mappedGroups[currentGroup.parentId].subGroups[currentGroup.id] = currentGroup;
- mappedGroups[currentGroup.parentId].isOpen = true; // Expand group if it has subgroups
+ mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
+ mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
} else if (parentGroup && parentGroup.id === currentGroup.parentId) {
- tree[currentGroup.id] = currentGroup;
+ tree[`id${currentGroup.id}`] = currentGroup;
} else {
- // Means the groups hast no direct parent.
- // Save for later processing, we will add them to its corresponding base group
+ // No parent found. We save it for later processing
orphans.push(currentGroup);
+
+ // Add to tree to preserve original order
+ tree[`id${currentGroup.id}`] = currentGroup;
}
} else {
- // If the group is at the root level, add it to first level elements array.
- tree[currentGroup.id] = currentGroup;
+ // If the group is at the top level, add it to first level elements array.
+ tree[`id${currentGroup.id}`] = currentGroup;
}
return key;
});
- // Hopefully this array will be empty for most cases
if (orphans.length) {
orphans.map((orphan) => {
let found = false;
@@ -83,11 +84,23 @@ export default class GroupsStore {
Object.keys(tree).map((key) => {
const group = tree[key];
- if (currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0) {
+
+ if (
+ group &&
+ currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
+ // Make sure the currently selected orphan is not the same as the group
+ // we are checking here otherwise it will end up in an infinite loop
+ currentOrphan.id !== group.id
+ ) {
group.subGroups[currentOrphan.id] = currentOrphan;
group.isOpen = true;
currentOrphan.isOrphan = true;
found = true;
+
+ // Delete if group was put at the top level. If not the group will be displayed twice.
+ if (tree[`id${currentOrphan.id}`]) {
+ delete tree[`id${currentOrphan.id}`];
+ }
}
return key;
@@ -95,7 +108,8 @@ export default class GroupsStore {
if (!found) {
currentOrphan.isOrphan = true;
- tree[currentOrphan.id] = currentOrphan;
+
+ tree[`id${currentOrphan.id}`] = currentOrphan;
}
return orphan;
@@ -140,7 +154,7 @@ export default class GroupsStore {
// eslint-disable-next-line class-methods-use-this
removeGroup(group, collection) {
- Vue.delete(collection, group.id);
+ Vue.delete(collection, `id${group.id}`);
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 2aca86189fd..122ec138c59 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -86,18 +86,25 @@
// This is required to handle non-unicode characters in hash
hash = decodeURIComponent(hash);
+ var fixedTabs = document.querySelector('.js-tabs-affix');
+ var fixedNav = document.querySelector('.navbar-gitlab');
+
+ var adjustment = 0;
+ if (fixedNav) adjustment -= fixedNav.offsetHeight;
+
// scroll to user-generated markdown anchor if we cannot find a match
if (document.getElementById(hash) === null) {
var target = document.getElementById('user-content-' + hash);
if (target && target.scrollIntoView) {
target.scrollIntoView(true);
+ window.scrollBy(0, adjustment);
}
} else {
// only adjust for fixedTabs when not targeting user-generated content
- var fixedTabs = document.querySelector('.js-tabs-affix');
if (fixedTabs) {
- window.scrollBy(0, -fixedTabs.offsetHeight);
+ adjustment -= fixedTabs.offsetHeight;
}
+ window.scrollBy(0, adjustment);
}
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 7bb2236017e..786b6014dc6 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -155,7 +155,10 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
scrollToElement(container) {
if (location.hash) {
- const offset = -$('.js-tabs-affix').outerHeight();
+ const offset = 0 - (
+ $('.navbar-gitlab').outerHeight() +
+ $('.js-tabs-affix').outerHeight()
+ );
const $el = $(`${container} ${location.hash}:not(.match)`);
if ($el.length) {
$.scrollTo($el[0], { offset });
@@ -291,7 +294,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
// Scroll any linked note into view
// Similar to `toggler_behavior` in the discussion tab
const hash = window.gl.utils.getLocationHash();
- const anchor = hash && $container.find(`[id="${hash}"]`);
+ const anchor = hash && $container.find(`.note[id="${hash}"]`);
if (anchor && anchor.length > 0) {
const notesContent = anchor.closest('.notes_content');
const lineType = notesContent.hasClass('new') ? 'new' : 'old';
@@ -301,6 +304,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
forceShow: true,
});
anchor[0].scrollIntoView();
+ window.gl.utils.handleLocationHash();
// We have multiple elements on the page with `#note_xxx`
// (discussion and diff tabs) and `:target` only applies to the first
anchor.addClass('target');
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 624dd336786..b21d7774920 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -4,7 +4,7 @@ no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line,
default-case, prefer-template, consistent-return, no-alert, no-return-assign,
no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new,
brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow,
-newline-per-chained-call, no-useless-escape */
+newline-per-chained-call, no-useless-escape, class-methods-use-this */
/* global Flash */
/* global Autosave */
/* global ResolveService */
@@ -25,1507 +25,1489 @@ import './task_list';
window.autosize = autosize;
window.Dropzone = Dropzone;
-const normalizeNewlines = function(str) {
+function normalizeNewlines(str) {
return str.replace(/\r\n/g, '\n');
-};
-
-(function() {
- this.Notes = (function() {
- const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
- const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
-
- Notes.interval = null;
-
- function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
- this.updateTargetButtons = this.updateTargetButtons.bind(this);
- this.updateComment = this.updateComment.bind(this);
- this.visibilityChange = this.visibilityChange.bind(this);
- this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this);
- this.onAddDiffNote = this.onAddDiffNote.bind(this);
- this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this);
- this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this);
- this.removeNote = this.removeNote.bind(this);
- this.cancelEdit = this.cancelEdit.bind(this);
- this.updateNote = this.updateNote.bind(this);
- this.addDiscussionNote = this.addDiscussionNote.bind(this);
- this.addNoteError = this.addNoteError.bind(this);
- this.addNote = this.addNote.bind(this);
- this.resetMainTargetForm = this.resetMainTargetForm.bind(this);
- this.refresh = this.refresh.bind(this);
- this.keydownNoteText = this.keydownNoteText.bind(this);
- this.toggleCommitList = this.toggleCommitList.bind(this);
- this.postComment = this.postComment.bind(this);
- this.clearFlashWrapper = this.clearFlash.bind(this);
- this.onHashChange = this.onHashChange.bind(this);
-
- this.notes_url = notes_url;
- this.note_ids = note_ids;
- this.enableGFM = enableGFM;
- // Used to keep track of updated notes while people are editing things
- 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.basePollingInterval = 15000;
- this.maxPollingSteps = 4;
-
- this.cleanBinding();
- this.addBinding();
- this.setPollingInterval();
- this.setupMainTargetNoteForm();
- this.taskList = new gl.TaskList({
- dataType: 'note',
- fieldName: 'note',
- selector: '.notes'
- });
- this.collapseLongCommitList();
- this.setViewType(view);
-
- // We are in the Merge Requests page so we need another edit form for Changes tab
- if (gl.utils.getPagePath(1) === 'merge_requests') {
- $('.note-edit-form').clone()
- .addClass('mr-note-edit-form').insertAfter('.note-edit-form');
- }
+}
+
+const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+
+export default class Notes {
+ constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
+ this.updateTargetButtons = this.updateTargetButtons.bind(this);
+ this.updateComment = this.updateComment.bind(this);
+ this.visibilityChange = this.visibilityChange.bind(this);
+ this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this);
+ this.onAddDiffNote = this.onAddDiffNote.bind(this);
+ this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this);
+ this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this);
+ this.removeNote = this.removeNote.bind(this);
+ this.cancelEdit = this.cancelEdit.bind(this);
+ this.updateNote = this.updateNote.bind(this);
+ this.addDiscussionNote = this.addDiscussionNote.bind(this);
+ this.addNoteError = this.addNoteError.bind(this);
+ this.addNote = this.addNote.bind(this);
+ this.resetMainTargetForm = this.resetMainTargetForm.bind(this);
+ this.refresh = this.refresh.bind(this);
+ this.keydownNoteText = this.keydownNoteText.bind(this);
+ this.toggleCommitList = this.toggleCommitList.bind(this);
+ this.postComment = this.postComment.bind(this);
+ this.clearFlashWrapper = this.clearFlash.bind(this);
+ this.onHashChange = this.onHashChange.bind(this);
+
+ this.notes_url = notes_url;
+ this.note_ids = note_ids;
+ this.enableGFM = enableGFM;
+ // Used to keep track of updated notes while people are editing things
+ 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.basePollingInterval = 15000;
+ this.maxPollingSteps = 4;
+
+ this.cleanBinding();
+ this.addBinding();
+ this.setPollingInterval();
+ this.setupMainTargetNoteForm();
+ this.taskList = new gl.TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes'
+ });
+ this.collapseLongCommitList();
+ this.setViewType(view);
+
+ // We are in the Merge Requests page so we need another edit form for Changes tab
+ if (gl.utils.getPagePath(1) === 'merge_requests') {
+ $('.note-edit-form').clone()
+ .addClass('mr-note-edit-form').insertAfter('.note-edit-form');
+ }
+ }
+
+ setViewType(view) {
+ this.view = Cookies.get('diff_view') || view;
+ }
+
+ addBinding() {
+ // Edit note link
+ $(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
+ $(document).on('click', '.note-edit-cancel', this.cancelEdit);
+ // Reopen and close actions for Issue/MR combined with note form submit
+ $(document).on('click', '.js-comment-submit-button', this.postComment);
+ $(document).on('click', '.js-comment-save-button', this.updateComment);
+ $(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
+ // resolve a discussion
+ $(document).on('click', '.js-comment-resolve-button', this.postComment);
+ // remove a note (in general)
+ $(document).on('click', '.js-note-delete', this.removeNote);
+ // delete note attachment
+ $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
+ // reset main target form when clicking discard
+ $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
+ // update the file name when an attachment is selected
+ $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
+ // reply to diff/discussion notes
+ $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
+ // add diff note
+ $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
+ // hide diff note form
+ $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
+ // toggle commit list
+ $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
+ // fetch notes when tab becomes visible
+ $(document).on('visibilitychange', this.visibilityChange);
+ // when issue status changes, we need to refresh data
+ $(document).on('issuable:change', this.refresh);
+ // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
+ $(document).on('ajax:success', '.js-main-target-form', this.addNote);
+ $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
+ $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
+ $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
+ // when a key is clicked on the notes
+ $(document).on('keydown', '.js-note-text', this.keydownNoteText);
+ // When the URL fragment/hash has changed, `#note_xxx`
+ return $(window).on('hashchange', this.onHashChange);
+ }
+
+ cleanBinding() {
+ $(document).off('click', '.js-note-edit');
+ $(document).off('click', '.note-edit-cancel');
+ $(document).off('click', '.js-note-delete');
+ $(document).off('click', '.js-note-attachment-delete');
+ $(document).off('click', '.js-discussion-reply-button');
+ $(document).off('click', '.js-add-diff-note-button');
+ $(document).off('visibilitychange');
+ $(document).off('keyup input', '.js-note-text');
+ $(document).off('click', '.js-note-target-reopen');
+ $(document).off('click', '.js-note-target-close');
+ $(document).off('click', '.js-note-discard');
+ $(document).off('keydown', '.js-note-text');
+ $(document).off('click', '.js-comment-resolve-button');
+ $(document).off('click', '.system-note-commit-list-toggler');
+ $(document).off('ajax:success', '.js-main-target-form');
+ $(document).off('ajax:success', '.js-discussion-note-form');
+ $(document).off('ajax:complete', '.js-main-target-form');
+ $(window).off('hashchange', this.onHashChange);
+ }
+
+ static initCommentTypeToggle(form) {
+ 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 closeButton = form.querySelector('.js-note-target-close');
+ const reopenButton = form.querySelector('.js-note-target-reopen');
+
+ const commentTypeToggle = new CommentTypeToggle({
+ dropdownTrigger,
+ dropdownList,
+ noteTypeInput,
+ submitButton,
+ closeButton,
+ reopenButton,
+ });
+
+ commentTypeToggle.initDroplab();
+ }
+
+ keydownNoteText(e) {
+ var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
+ if (gl.utils.isMetaKey(e)) {
+ return;
}
- Notes.prototype.setViewType = function(view) {
- this.view = Cookies.get('diff_view') || view;
- };
-
- Notes.prototype.addBinding = function() {
- // Edit note link
- $(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
- $(document).on('click', '.note-edit-cancel', this.cancelEdit);
- // Reopen and close actions for Issue/MR combined with note form submit
- $(document).on('click', '.js-comment-submit-button', this.postComment);
- $(document).on('click', '.js-comment-save-button', this.updateComment);
- $(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
- // resolve a discussion
- $(document).on('click', '.js-comment-resolve-button', this.postComment);
- // remove a note (in general)
- $(document).on('click', '.js-note-delete', this.removeNote);
- // delete note attachment
- $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
- // reset main target form when clicking discard
- $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
- // update the file name when an attachment is selected
- $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
- // reply to diff/discussion notes
- $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
- // add diff note
- $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
- // hide diff note form
- $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
- // toggle commit list
- $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
- // fetch notes when tab becomes visible
- $(document).on('visibilitychange', this.visibilityChange);
- // when issue status changes, we need to refresh data
- $(document).on('issuable:change', this.refresh);
- // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
- $(document).on('ajax:success', '.js-main-target-form', this.addNote);
- $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
- $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
- $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
- // when a key is clicked on the notes
- $(document).on('keydown', '.js-note-text', this.keydownNoteText);
- // When the URL fragment/hash has changed, `#note_xxx`
- return $(window).on('hashchange', this.onHashChange);
- };
-
- Notes.prototype.cleanBinding = function() {
- $(document).off('click', '.js-note-edit');
- $(document).off('click', '.note-edit-cancel');
- $(document).off('click', '.js-note-delete');
- $(document).off('click', '.js-note-attachment-delete');
- $(document).off('click', '.js-discussion-reply-button');
- $(document).off('click', '.js-add-diff-note-button');
- $(document).off('visibilitychange');
- $(document).off('keyup input', '.js-note-text');
- $(document).off('click', '.js-note-target-reopen');
- $(document).off('click', '.js-note-target-close');
- $(document).off('click', '.js-note-discard');
- $(document).off('keydown', '.js-note-text');
- $(document).off('click', '.js-comment-resolve-button');
- $(document).off('click', '.system-note-commit-list-toggler');
- $(document).off('ajax:success', '.js-main-target-form');
- $(document).off('ajax:success', '.js-discussion-note-form');
- $(document).off('ajax:complete', '.js-main-target-form');
- $(window).off('hashchange', this.onHashChange);
- };
-
- Notes.initCommentTypeToggle = function (form) {
- 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 closeButton = form.querySelector('.js-note-target-close');
- const reopenButton = form.querySelector('.js-note-target-reopen');
-
- const commentTypeToggle = new CommentTypeToggle({
- dropdownTrigger,
- dropdownList,
- noteTypeInput,
- submitButton,
- closeButton,
- reopenButton,
- });
-
- commentTypeToggle.initDroplab();
- };
-
- Notes.prototype.keydownNoteText = function(e) {
- var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
- if (gl.utils.isMetaKey(e)) {
- return;
- }
-
- $textarea = $(e.target);
- // Edit previous note when UP arrow is hit
- switch (e.which) {
- case 38:
+ $textarea = $(e.target);
+ // Edit previous note when UP arrow is hit
+ switch (e.which) {
+ case 38:
+ if ($textarea.val() !== '') {
+ return;
+ }
+ 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]);
+ }
+ break;
+ // Cancel creating diff note or editing any note when ESCAPE is hit
+ case 27:
+ discussionNoteForm = $textarea.closest('.js-discussion-note-form');
+ if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- return;
- }
- 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]);
- }
- break;
- // Cancel creating diff note or editing any note when ESCAPE is hit
- case 27:
- 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?')) {
- return;
- }
+ if (!confirm('Are you sure you want to cancel creating this comment?')) {
+ return;
}
- this.removeDiscussionNoteForm(discussionNoteForm);
- return;
}
- editNote = $textarea.closest('.note');
- if (editNote.length) {
- originalText = $textarea.closest('form').data('original-note');
- newText = $textarea.val();
- if (originalText !== newText) {
- if (!confirm('Are you sure you want to cancel editing this comment?')) {
- return;
- }
+ this.removeDiscussionNoteForm(discussionNoteForm);
+ return;
+ }
+ editNote = $textarea.closest('.note');
+ if (editNote.length) {
+ originalText = $textarea.closest('form').data('original-note');
+ newText = $textarea.val();
+ if (originalText !== newText) {
+ if (!confirm('Are you sure you want to cancel editing this comment?')) {
+ return;
}
- return this.removeNoteEditForm(editNote);
}
- }
- };
+ return this.removeNoteEditForm(editNote);
+ }
+ }
+ }
- Notes.prototype.initRefresh = function() {
+ initRefresh() {
+ 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);
+ }
- Notes.prototype.refresh = function() {
- if (!document.hidden) {
- return this.getContent();
- }
- };
+ refresh() {
+ if (!document.hidden) {
+ return this.getContent();
+ }
+ }
- Notes.prototype.getContent = function() {
- if (this.refreshing) {
- return;
- }
- this.refreshing = true;
- return $.ajax({
- url: this.notes_url,
- headers: { 'X-Last-Fetched-At': this.last_fetched_at },
- dataType: 'json',
- success: (function(_this) {
- return function(data) {
- var notes;
- notes = data.notes;
- _this.last_fetched_at = data.last_fetched_at;
- _this.setPollingInterval(data.notes.length);
- return $.each(notes, function(i, note) {
- _this.renderNote(note);
- });
- };
- })(this)
- }).always((function(_this) {
- return function() {
- return _this.refreshing = false;
+ getContent() {
+ if (this.refreshing) {
+ return;
+ }
+ this.refreshing = true;
+ return $.ajax({
+ url: this.notes_url,
+ headers: { 'X-Last-Fetched-At': this.last_fetched_at },
+ dataType: 'json',
+ success: (function(_this) {
+ return function(data) {
+ var notes;
+ notes = data.notes;
+ _this.last_fetched_at = data.last_fetched_at;
+ _this.setPollingInterval(data.notes.length);
+ return $.each(notes, function(i, note) {
+ _this.renderNote(note);
+ });
};
- })(this));
- };
-
- /*
- Increase @pollingInterval up to 120 seconds on every function call,
- if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
- will reset to @basePollingInterval.
-
- Note: this function is used to gradually increase the polling interval
- if there aren't new notes coming from the server
- */
-
- Notes.prototype.setPollingInterval = function(shouldReset) {
- var nthInterval;
- if (shouldReset == null) {
- shouldReset = true;
- }
- nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
- if (shouldReset) {
- this.pollingInterval = this.basePollingInterval;
- } else if (this.pollingInterval < nthInterval) {
- this.pollingInterval *= 2;
+ })(this)
+ }).always((function(_this) {
+ return function() {
+ return _this.refreshing = false;
+ };
+ })(this));
+ }
+
+ /**
+ * Increase @pollingInterval up to 120 seconds on every function call,
+ * if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
+ * will reset to @basePollingInterval.
+ *
+ * Note: this function is used to gradually increase the polling interval
+ * if there aren't new notes coming from the server
+ */
+ setPollingInterval(shouldReset) {
+ var nthInterval;
+ if (shouldReset == null) {
+ shouldReset = true;
+ }
+ nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ if (shouldReset) {
+ this.pollingInterval = this.basePollingInterval;
+ } else if (this.pollingInterval < nthInterval) {
+ this.pollingInterval *= 2;
+ }
+ return this.initRefresh();
+ }
+
+ handleQuickActions(noteEntity) {
+ var votesBlock;
+ if (noteEntity.commands_changes) {
+ if ('merge' in noteEntity.commands_changes) {
+ Notes.checkMergeRequestStatus();
}
- return this.initRefresh();
- };
-
- Notes.prototype.handleQuickActions = function(noteEntity) {
- var votesBlock;
- if (noteEntity.commands_changes) {
- if ('merge' in noteEntity.commands_changes) {
- Notes.checkMergeRequestStatus();
- }
- if ('emoji_award' in noteEntity.commands_changes) {
- votesBlock = $('.js-awards-block').eq(0);
- gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
- return gl.awardsHandler.scrollToAwards();
- }
+ if ('emoji_award' in noteEntity.commands_changes) {
+ votesBlock = $('.js-awards-block').eq(0);
+ gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
+ return gl.awardsHandler.scrollToAwards();
}
- };
-
- Notes.prototype.setupNewNote = function($note) {
- // Update datetime format on the recent note
- gl.utils.localTimeAgo($note.find('.js-timeago'), false);
-
- this.collapseLongCommitList();
- this.taskList.init();
-
- // This stops the note highlight, #note_xxx`, from being removed after real time update
- // The `:target` selector does not re-evaluate after we replace element in the DOM
- Notes.updateNoteTargetSelector($note);
- this.$noteToCleanHighlight = $note;
- };
-
- Notes.prototype.onHashChange = function() {
- if (this.$noteToCleanHighlight) {
- Notes.updateNoteTargetSelector(this.$noteToCleanHighlight);
- }
-
- this.$noteToCleanHighlight = null;
- };
-
- Notes.updateNoteTargetSelector = function($note) {
- const hash = gl.utils.getLocationHash();
- // Needs to be an explicit true/false for the jQuery `toggleClass(force)`
- const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0);
- $note.toggleClass('target', addTargetClass);
- };
-
- /*
- Render note in main comments area.
+ }
+ }
- Note: for rendering inline notes use renderDiscussionNote
- */
+ setupNewNote($note) {
+ // Update datetime format on the recent note
+ gl.utils.localTimeAgo($note.find('.js-timeago'), false);
- Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) {
- if (noteEntity.discussion_html) {
- return this.renderDiscussionNote(noteEntity, $form);
- }
+ this.collapseLongCommitList();
+ this.taskList.init();
- if (!noteEntity.valid) {
- if (noteEntity.errors.commands_only) {
- this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
- this.refresh();
- }
- return;
- }
+ // This stops the note highlight, #note_xxx`, from being removed after real time update
+ // The `:target` selector does not re-evaluate after we replace element in the DOM
+ Notes.updateNoteTargetSelector($note);
+ this.$noteToCleanHighlight = $note;
+ }
- const $note = $notesList.find(`#note_${noteEntity.id}`);
- if (Notes.isNewNote(noteEntity, this.note_ids)) {
- this.note_ids.push(noteEntity.id);
+ onHashChange() {
+ if (this.$noteToCleanHighlight) {
+ Notes.updateNoteTargetSelector(this.$noteToCleanHighlight);
+ }
- if ($notesList.length) {
- $notesList.find('.system-note.being-posted').remove();
- }
- const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
+ this.$noteToCleanHighlight = null;
+ }
+
+ static updateNoteTargetSelector($note) {
+ const hash = gl.utils.getLocationHash();
+ // Needs to be an explicit true/false for the jQuery `toggleClass(force)`
+ const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0);
+ $note.toggleClass('target', addTargetClass);
+ }
+
+ /**
+ * Render note in main comments area.
+ *
+ * Note: for rendering inline notes use renderDiscussionNote
+ */
+ renderNote(noteEntity, $form, $notesList = $('.main-notes-list')) {
+ if (noteEntity.discussion_html) {
+ return this.renderDiscussionNote(noteEntity, $form);
+ }
- this.setupNewNote($newNote);
+ if (!noteEntity.valid) {
+ if (noteEntity.errors.commands_only) {
+ this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
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)) {
- const isEditing = $note.hasClass('is-editing');
- const initialContent = normalizeNewlines(
- $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;
-
- if (isEditing && isTextareaUntouched) {
- $textarea.val(noteEntity.note);
- this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
- }
- else if (isEditing && !isTextareaUntouched) {
- this.putConflictEditWarningInPlace(noteEntity, $note);
- this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
- }
- else {
- const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
- this.setupNewNote($updatedNote);
- }
- }
- };
-
- Notes.prototype.isParallelView = function() {
- return Cookies.get('diff_view') === 'parallel';
- };
-
- /*
- Render note in discussion area.
-
- Note: for rendering inline notes use renderDiscussionNote
- */
+ return;
+ }
- Notes.prototype.renderDiscussionNote = function(noteEntity, $form) {
- var discussionContainer, form, row, lineType, diffAvatarContainer;
- if (!Notes.isNewNote(noteEntity, this.note_ids)) {
- return;
- }
+ const $note = $notesList.find(`#note_${noteEntity.id}`);
+ if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id);
- form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
- row = form.closest('tr');
- lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
- 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}"]`);
- if (!discussionContainer.length) {
- discussionContainer = form.closest('.discussion').find('.notes');
- }
- if (discussionContainer.length === 0) {
- if (noteEntity.diff_discussion_html) {
- var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
-
- if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
- // 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());
- }
- }
- // 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) {
- Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
- }
- } else {
- // append new note to all matching discussions
- Notes.animateAppendNote(noteEntity.html, discussionContainer);
- }
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
- gl.diffNotesCompileComponents();
- this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
+ if ($notesList.length) {
+ $notesList.find('.system-note.being-posted').remove();
}
+ const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
- gl.utils.localTimeAgo($('.js-timeago'), false);
- Notes.checkMergeRequestStatus();
+ this.setupNewNote($newNote);
+ this.refresh();
return this.updateNotesCount(1);
- };
-
- Notes.prototype.getLineHolder = function(changesDiscussionContainer) {
- return $(changesDiscussionContainer).closest('.notes_holder')
- .prevAll('.line_holder')
- .first()
- .get(0);
- };
-
- Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, noteEntity) {
- var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
- var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
-
- if (!avatarHolder.length) {
- avatarHolder = document.createElement('diff-note-avatars');
- avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
-
- diffAvatarContainer.append(avatarHolder);
+ }
+ // 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)) {
+ const isEditing = $note.hasClass('is-editing');
+ const initialContent = normalizeNewlines(
+ $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;
- gl.diffNotesCompileComponents();
+ if (isEditing && isTextareaUntouched) {
+ $textarea.val(noteEntity.note);
+ this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
}
-
- if (commentButton.length) {
- commentButton.remove();
+ else if (isEditing && !isTextareaUntouched) {
+ this.putConflictEditWarningInPlace(noteEntity, $note);
+ this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
}
- };
-
- /*
- Called in response the main target form has been successfully submitted.
-
- Removes any errors.
- Resets text and preview.
- Resets buttons.
- */
-
- Notes.prototype.resetMainTargetForm = function(e) {
- var form;
- form = $('.js-main-target-form');
- // remove validation errors
- 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();
-
- var event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- form.find('.js-autosize')[0].dispatchEvent(event);
-
- this.updateTargetButtons(e);
- };
-
- Notes.prototype.reenableTargetFormSubmitButton = function() {
- var form;
- form = $('.js-main-target-form');
- return form.find('.js-note-text').trigger('input');
- };
-
- /*
- Shows the main form and does some setup on it.
-
- Sets some hidden fields in the form.
- */
-
- Notes.prototype.setupMainTargetNoteForm = function() {
- var form;
- // find the form
- form = $('.js-new-note-form');
- // Set a global clone of the form for later cloning
- this.formClone = form.clone();
- // show the form
- this.setupNoteForm(form);
- // fix classes
- form.removeClass('js-new-note-form');
- form.addClass('js-main-target-form');
- form.find('#note_line_code').remove();
- form.find('#note_position').remove();
- form.find('#note_type').val('');
- form.find('#in_reply_to_discussion_id').remove();
- form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
- this.parentTimeline = form.parents('.timeline');
-
- if (form.length) {
- Notes.initCommentTypeToggle(form.get(0));
- }
- };
-
- /*
- General note form setup.
-
- deactivates the submit button when text is empty
- hides the preview button when text is empty
- setup GFM auto complete
- show the form
- */
-
- Notes.prototype.setupNoteForm = function(form) {
- var textarea, key;
- new gl.GLForm(form, this.enableGFM);
- textarea = form.find('.js-note-text');
- key = [
- 'Note',
- form.find('#note_noteable_type').val(),
- form.find('#note_noteable_id').val(),
- form.find('#note_commit_id').val(),
- form.find('#note_type').val(),
- form.find('#in_reply_to_discussion_id').val(),
-
- // LegacyDiffNote
- form.find('#note_line_code').val(),
-
- // DiffNote
- form.find('#note_position').val()
- ];
- return new Autosave(textarea, key);
- };
-
- /*
- Called in response to the new note form being submitted
-
- Adds new note to list.
- */
-
- Notes.prototype.addNote = function($form, note) {
- return this.renderNote(note);
- };
-
- Notes.prototype.addNoteError = function($form) {
- let formParentTimeline;
- if ($form.hasClass('js-main-target-form')) {
- formParentTimeline = $form.parents('.timeline');
- } else if ($form.hasClass('js-discussion-note-form')) {
- formParentTimeline = $form.closest('.discussion-notes').find('.notes');
+ else {
+ const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
+ this.setupNewNote($updatedNote);
}
- return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline);
- };
-
- Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.');
-
- /*
- Called in response to the new note form being submitted
-
- Adds new note to list.
- */
-
- Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) {
- if ($form.attr('data-resolve-all') != null) {
- var projectPath = $form.data('project-path');
- var discussionId = $form.data('discussion-id');
- var mergeRequestId = $form.data('noteable-iid');
+ }
+ }
+
+ isParallelView() {
+ return Cookies.get('diff_view') === 'parallel';
+ }
+
+ /**
+ * Render note in discussion area.
+ *
+ * Note: for rendering inline notes use renderDiscussionNote
+ */
+ renderDiscussionNote(noteEntity, $form) {
+ var discussionContainer, form, row, lineType, diffAvatarContainer;
+ if (!Notes.isNewNote(noteEntity, this.note_ids)) {
+ return;
+ }
+ this.note_ids.push(noteEntity.id);
+ form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
+ row = form.closest('tr');
+ lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
+ 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}"]`);
+ if (!discussionContainer.length) {
+ discussionContainer = form.closest('.discussion').find('.notes');
+ }
+ if (discussionContainer.length === 0) {
+ if (noteEntity.diff_discussion_html) {
+ var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
- if (ResolveService != null) {
- ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
+ if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
+ // 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());
}
}
-
- this.renderNote(note, $form);
- // cleanup after successfully creating a diff/discussion note
- if (isNewDiffComment) {
- this.removeDiscussionNoteForm($form);
+ // 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) {
+ Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
- };
+ } else {
+ // append new note to all matching discussions
+ Notes.animateAppendNote(noteEntity.html, discussionContainer);
+ }
- /*
- Called in response to the edit note form being submitted
+ if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
+ gl.diffNotesCompileComponents();
+ this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
+ }
- Updates the current note field.
- */
+ gl.utils.localTimeAgo($('.js-timeago'), false);
+ Notes.checkMergeRequestStatus();
+ return this.updateNotesCount(1);
+ }
- Notes.prototype.updateNote = function(noteEntity, $targetNote) {
- var $noteEntityEl, $note_li;
- // Convert returned HTML to a jQuery object so we can modify it further
- $noteEntityEl = $(noteEntity.html);
- $noteEntityEl.addClass('fade-in-full');
- this.revertNoteEditForm($targetNote);
- $noteEntityEl.renderGFM();
- // Find the note's `li` element by ID and replace it with the updated HTML
- $note_li = $('.note-row-' + noteEntity.id);
+ getLineHolder(changesDiscussionContainer) {
+ return $(changesDiscussionContainer).closest('.notes_holder')
+ .prevAll('.line_holder')
+ .first()
+ .get(0);
+ }
- $note_li.replaceWith($noteEntityEl);
- this.setupNewNote($noteEntityEl);
+ renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
+ var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
+ var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
- };
+ if (!avatarHolder.length) {
+ avatarHolder = document.createElement('diff-note-avatars');
+ avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
- Notes.prototype.checkContentToAllowEditing = function($el) {
- var initialContent = $el.find('.original-note-content').text().trim();
- var currentContent = $el.find('.js-note-text').val();
- var isAllowed = true;
+ diffAvatarContainer.append(avatarHolder);
- if (currentContent === initialContent) {
- this.removeNoteEditForm($el);
- }
- else {
- var $buttons = $el.find('.note-form-actions');
- var isWidgetVisible = gl.utils.isInViewport($el.get(0));
-
- if (!isWidgetVisible) {
- gl.utils.scrollToElement($el);
- }
+ gl.diffNotesCompileComponents();
+ }
- $el.find('.js-finish-edit-warning').show();
- isAllowed = false;
- }
+ if (commentButton.length) {
+ commentButton.remove();
+ }
+ }
+
+ /**
+ * Called in response the main target form has been successfully submitted.
+ *
+ * Removes any errors.
+ * Resets text and preview.
+ * Resets buttons.
+ */
+ resetMainTargetForm(e) {
+ var form;
+ form = $('.js-main-target-form');
+ // remove validation errors
+ 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();
+
+ var event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ form.find('.js-autosize')[0].dispatchEvent(event);
+
+ this.updateTargetButtons(e);
+ }
+
+ reenableTargetFormSubmitButton() {
+ var form;
+ form = $('.js-main-target-form');
+ return form.find('.js-note-text').trigger('input');
+ }
+
+ /**
+ * Shows the main form and does some setup on it.
+ *
+ * Sets some hidden fields in the form.
+ */
+ setupMainTargetNoteForm() {
+ var form;
+ // find the form
+ form = $('.js-new-note-form');
+ // Set a global clone of the form for later cloning
+ this.formClone = form.clone();
+ // show the form
+ this.setupNoteForm(form);
+ // fix classes
+ form.removeClass('js-new-note-form');
+ form.addClass('js-main-target-form');
+ form.find('#note_line_code').remove();
+ form.find('#note_position').remove();
+ form.find('#note_type').val('');
+ form.find('#in_reply_to_discussion_id').remove();
+ form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
+ this.parentTimeline = form.parents('.timeline');
+
+ if (form.length) {
+ Notes.initCommentTypeToggle(form.get(0));
+ }
+ }
+
+ /**
+ * General note form setup.
+ *
+ * deactivates the submit button when text is empty
+ * hides the preview button when text is empty
+ * setup GFM auto complete
+ * show the form
+ */
+ setupNoteForm(form) {
+ var textarea, key;
+ new gl.GLForm(form, this.enableGFM);
+ textarea = form.find('.js-note-text');
+ key = [
+ 'Note',
+ form.find('#note_noteable_type').val(),
+ form.find('#note_noteable_id').val(),
+ form.find('#note_commit_id').val(),
+ form.find('#note_type').val(),
+ form.find('#in_reply_to_discussion_id').val(),
- return isAllowed;
- };
+ // LegacyDiffNote
+ form.find('#note_line_code').val(),
- /*
- Called in response to clicking the edit note link
+ // DiffNote
+ form.find('#note_position').val()
+ ];
+ return new Autosave(textarea, key);
+ }
+
+ /**
+ * Called in response to the new note form being submitted
+ *
+ * Adds new note to list.
+ */
+ addNote($form, note) {
+ return this.renderNote(note);
+ }
+
+ addNoteError($form) {
+ let formParentTimeline;
+ if ($form.hasClass('js-main-target-form')) {
+ formParentTimeline = $form.parents('.timeline');
+ } 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);
+ }
+
+ updateNoteError($parentTimeline) {
+ new Flash('Your comment could not be updated! Please check your network connection and try again.');
+ }
+
+ /**
+ * Called in response to the new note form being submitted
+ *
+ * Adds new note to list.
+ */
+ addDiscussionNote($form, note, isNewDiffComment) {
+ if ($form.attr('data-resolve-all') != null) {
+ var projectPath = $form.data('project-path');
+ var discussionId = $form.data('discussion-id');
+ var mergeRequestId = $form.data('noteable-iid');
+
+ if (ResolveService != null) {
+ ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
+ }
+ }
- Replaces the note text with the note edit form
- Adds a data attribute to the form with the original content of the note for cancellations
- */
- Notes.prototype.showEditForm = function(e, scrollTo, myLastNote) {
- e.preventDefault();
+ this.renderNote(note, $form);
+ // cleanup after successfully creating a diff/discussion note
+ if (isNewDiffComment) {
+ this.removeDiscussionNoteForm($form);
+ }
+ }
+
+ /**
+ * Called in response to the edit note form being submitted
+ *
+ * Updates the current note field.
+ */
+ updateNote(noteEntity, $targetNote) {
+ var $noteEntityEl, $note_li;
+ // Convert returned HTML to a jQuery object so we can modify it further
+ $noteEntityEl = $(noteEntity.html);
+ $noteEntityEl.addClass('fade-in-full');
+ this.revertNoteEditForm($targetNote);
+ $noteEntityEl.renderGFM();
+ // Find the note's `li` element by ID and replace it with the updated HTML
+ $note_li = $('.note-row-' + noteEntity.id);
+
+ $note_li.replaceWith($noteEntityEl);
+ this.setupNewNote($noteEntityEl);
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
+ }
+ }
- var $target = $(e.target);
- var $editForm = $(this.getEditFormSelector($target));
- var $note = $target.closest('.note');
- var $currentlyEditing = $('.note.is-editing:visible');
+ checkContentToAllowEditing($el) {
+ var initialContent = $el.find('.original-note-content').text().trim();
+ var currentContent = $el.find('.js-note-text').val();
+ var isAllowed = true;
- if ($currentlyEditing.length) {
- var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
+ if (currentContent === initialContent) {
+ this.removeNoteEditForm($el);
+ }
+ else {
+ var $buttons = $el.find('.note-form-actions');
+ var isWidgetVisible = gl.utils.isInViewport($el.get(0));
- if (!isEditAllowed) {
- return;
- }
+ if (!isWidgetVisible) {
+ gl.utils.scrollToElement($el);
}
- $note.find('.js-note-attachment-delete').show();
- $editForm.addClass('current-note-edit-form');
- $note.addClass('is-editing');
- this.putEditFormInPlace($target);
- };
+ $el.find('.js-finish-edit-warning').show();
+ isAllowed = false;
+ }
- /*
- Called in response to clicking the edit note link
+ return isAllowed;
+ }
- Hides edit form and restores the original note text to the editor textarea.
- */
+ /**
+ * Called in response to clicking the edit note link
+ *
+ * Replaces the note text with the note edit form
+ * Adds a data attribute to the form with the original content of the note for cancellations
+ */
+ showEditForm(e, scrollTo, myLastNote) {
+ e.preventDefault();
- Notes.prototype.cancelEdit = function(e) {
- e.preventDefault();
- const $target = $(e.target);
- const $note = $target.closest('.note');
- const noteId = $note.attr('data-note-id');
+ var $target = $(e.target);
+ var $editForm = $(this.getEditFormSelector($target));
+ var $note = $target.closest('.note');
+ var $currentlyEditing = $('.note.is-editing:visible');
- this.revertNoteEditForm($target);
+ if ($currentlyEditing.length) {
+ var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
- if (this.updatedNotesTrackingMap[noteId]) {
- const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
- $note.replaceWith($newNote);
- this.setupNewNote($newNote);
- // Now that we have taken care of the update, clear it out
- delete this.updatedNotesTrackingMap[noteId];
- }
- else {
- $note.find('.js-finish-edit-warning').hide();
- this.removeNoteEditForm($note);
+ if (!isEditAllowed) {
+ return;
}
- };
-
- Notes.prototype.revertNoteEditForm = function($target) {
- $target = $target || $('.note.is-editing:visible');
- var selector = this.getEditFormSelector($target);
- var $editForm = $(selector);
+ }
- $editForm.insertBefore('.notes-form');
- $editForm.find('.js-comment-save-button').enable();
- $editForm.find('.js-finish-edit-warning').hide();
- };
+ $note.find('.js-note-attachment-delete').show();
+ $editForm.addClass('current-note-edit-form');
+ $note.addClass('is-editing');
+ this.putEditFormInPlace($target);
+ }
+
+ /**
+ * Called in response to clicking the edit note link
+ *
+ * Hides edit form and restores the original note text to the editor textarea.
+ */
+ cancelEdit(e) {
+ e.preventDefault();
+ const $target = $(e.target);
+ const $note = $target.closest('.note');
+ const noteId = $note.attr('data-note-id');
+
+ this.revertNoteEditForm($target);
+
+ if (this.updatedNotesTrackingMap[noteId]) {
+ const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
+ $note.replaceWith($newNote);
+ this.setupNewNote($newNote);
+ // Now that we have taken care of the update, clear it out
+ delete this.updatedNotesTrackingMap[noteId];
+ }
+ else {
+ $note.find('.js-finish-edit-warning').hide();
+ this.removeNoteEditForm($note);
+ }
+ }
- Notes.prototype.getEditFormSelector = function($el) {
- var selector = '.note-edit-form:not(.mr-note-edit-form)';
+ revertNoteEditForm($target) {
+ $target = $target || $('.note.is-editing:visible');
+ var selector = this.getEditFormSelector($target);
+ var $editForm = $(selector);
- if ($el.parents('#diffs').length) {
- selector = '.note-edit-form.mr-note-edit-form';
- }
+ $editForm.insertBefore('.notes-form');
+ $editForm.find('.js-comment-save-button').enable();
+ $editForm.find('.js-finish-edit-warning').hide();
+ }
- return selector;
- };
+ getEditFormSelector($el) {
+ var selector = '.note-edit-form:not(.mr-note-edit-form)';
- Notes.prototype.removeNoteEditForm = function($note) {
- var form = $note.find('.current-note-edit-form');
- $note.removeClass('is-editing');
- 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('original-note'));
- };
+ if ($el.parents('#diffs').length) {
+ selector = '.note-edit-form.mr-note-edit-form';
+ }
- /*
- Called in response to deleting a note of any kind.
-
- Removes the actual note from view.
- Removes the whole discussion if the last note is being removed.
- */
-
- Notes.prototype.removeNote = function(e) {
- var noteElId, noteId, dataNoteId, $note, lineHolder;
- $note = $(e.currentTarget).closest('.note');
- noteElId = $note.attr('id');
- noteId = $note.attr('data-note-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');
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- if (gl.diffNoteApps[noteElId]) {
- gl.diffNoteApps[noteElId].$destroy();
- }
+ return selector;
+ }
+
+ removeNoteEditForm($note) {
+ var form = $note.find('.current-note-edit-form');
+ $note.removeClass('is-editing');
+ 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('original-note'));
+ }
+
+ /**
+ * Called in response to deleting a note of any kind.
+ *
+ * Removes the actual note from view.
+ * Removes the whole discussion if the last note is being removed.
+ */
+ removeNote(e) {
+ var noteElId, noteId, dataNoteId, $note, lineHolder;
+ $note = $(e.currentTarget).closest('.note');
+ noteElId = $note.attr('id');
+ noteId = $note.attr('data-note-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');
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ if (gl.diffNoteApps[noteElId]) {
+ gl.diffNoteApps[noteElId].$destroy();
}
+ }
- $note.remove();
+ $note.remove();
- // check if this is the last note for this line
- if ($notes.find('.note').length === 0) {
- var notesTr = $notes.closest('tr');
+ // 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();
+ // "Discussions" tab
+ $notes.closest('.timeline-entry').remove();
- // The notes tr can contain multiple lists of notes, like on the parallel diff
- if (notesTr.find('.discussion-notes').length > 1) {
- $notes.remove();
- } else {
- notesTr.remove();
- }
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ if (notesTr.find('.discussion-notes').length > 1) {
+ $notes.remove();
+ } else {
+ notesTr.remove();
}
- };
- })(this));
-
- Notes.checkMergeRequestStatus();
- return this.updateNotesCount(-1);
- };
-
- /*
- Called in response to clicking the delete attachment link
-
- Removes the attachment wrapper view, including image tag if it exists
- Resets the note editing form
- */
+ }
+ };
+ })(this));
+
+ Notes.checkMergeRequestStatus();
+ return this.updateNotesCount(-1);
+ }
+
+ /**
+ * Called in response to clicking the delete attachment link
+ *
+ * Removes the attachment wrapper view, including image tag if it exists
+ * Resets the note editing form
+ */
+ removeAttachment() {
+ const $note = $(this).closest('.note');
+ $note.find('.note-attachment').remove();
+ $note.find('.note-body > .note-text').show();
+ $note.find('.note-header').show();
+ return $note.find('.current-note-edit-form').remove();
+ }
+
+ /**
+ * Called when clicking on the "reply" button for a diff line.
+ *
+ * Shows the note form below the notes.
+ */
+ onReplyToDiscussionNote(e) {
+ this.replyToDiscussionNote(e.target);
+ }
+
+ replyToDiscussionNote(target) {
+ var form, replyLink;
+ form = this.cleanForm(this.formClone.clone());
+ replyLink = $(target).closest('.js-discussion-reply-button');
+ // insert the form after the button
+ replyLink
+ .closest('.discussion-reply-holder')
+ .hide()
+ .after(form);
+ // show the form
+ return this.setupDiscussionNoteForm(replyLink, form);
+ }
+
+ /**
+ * Shows the diff or discussion form and does some setup on it.
+ *
+ * Sets some hidden fields in the form.
+ *
+ * Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
+ */
+ setupDiscussionNoteForm(dataHolder, form) {
+ // setup note target
+ var discussionID = dataHolder.data('discussionId');
+
+ if (discussionID) {
+ form.attr('data-discussion-id', discussionID);
+ form.find('#in_reply_to_discussion_id').val(discussionID);
+ }
- Notes.prototype.removeAttachment = function() {
- const $note = $(this).closest('.note');
- $note.find('.note-attachment').remove();
- $note.find('.note-body > .note-text').show();
- $note.find('.note-header').show();
- return $note.find('.current-note-edit-form').remove();
- };
+ form.attr('data-line-code', dataHolder.data('lineCode'));
+ form.find('#line_type').val(dataHolder.data('lineType'));
- /*
- Called when clicking on the "reply" button for a diff line.
+ form.find('#note_noteable_type').val(dataHolder.data('noteableType'));
+ form.find('#note_noteable_id').val(dataHolder.data('noteableId'));
+ form.find('#note_commit_id').val(dataHolder.data('commitId'));
+ form.find('#note_type').val(dataHolder.data('noteType'));
- Shows the note form below the notes.
- */
+ // LegacyDiffNote
+ form.find('#note_line_code').val(dataHolder.data('lineCode'));
- Notes.prototype.onReplyToDiscussionNote = function(e) {
- this.replyToDiscussionNote(e.target);
- };
+ // DiffNote
+ form.find('#note_position').val(dataHolder.attr('data-position'));
- Notes.prototype.replyToDiscussionNote = function(target) {
- var form, replyLink;
- form = this.cleanForm(this.formClone.clone());
- replyLink = $(target).closest('.js-discussion-reply-button');
- // insert the form after the button
- replyLink
- .closest('.discussion-reply-holder')
- .hide()
- .after(form);
- // show the form
- return this.setupDiscussionNoteForm(replyLink, form);
- };
+ 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('cancel-text'));
+ form.find('.js-note-target-close').remove();
+ form.find('.js-note-new-discussion').remove();
+ this.setupNoteForm(form);
- /*
- Shows the diff or discussion form and does some setup on it.
+ form
+ .removeClass('js-main-target-form')
+ .addClass('discussion-form js-discussion-note-form');
- Sets some hidden fields in the form.
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ var $commentBtn = form.find('comment-and-resolve-btn');
+ $commentBtn.attr(':discussion-id', `'${discussionID}'`);
- Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
- */
+ gl.diffNotesCompileComponents();
+ }
- Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
- // setup note target
- var discussionID = dataHolder.data('discussionId');
+ form.find('.js-note-text').focus();
+ form
+ .find('.js-comment-resolve-button')
+ .attr('data-discussion-id', discussionID);
+ }
+
+ /**
+ * Called when clicking on the "add a comment" button on the side of a diff line.
+ *
+ * Inserts a temporary row for the form below the line.
+ * Sets up the form and shows it.
+ */
+ onAddDiffNote(e) {
+ e.preventDefault();
+ const link = e.currentTarget || e.target;
+ const $link = $(link);
+ const showReplyInput = !$link.hasClass('js-diff-comment-avatar');
+ this.toggleDiffNote({
+ target: $link,
+ lineType: link.dataset.lineType,
+ showReplyInput
+ });
+ }
+
+ 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();
+ let targetRow = row;
+ if (nextRow.is('.notes_holder')) {
+ targetRow = nextRow;
+ }
- if (discussionID) {
- form.attr('data-discussion-id', discussionID);
- form.find('#in_reply_to_discussion_id').val(discussionID);
+ 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>';
+ // 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>';
+ }
+ const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
+ let notesContent = targetRow.find(notesContentSelector);
+
+ if (hasNotes && showReplyInput) {
+ targetRow.show();
+ notesContent = targetRow.find(notesContentSelector);
+ if (notesContent.length) {
+ notesContent.show();
+ replyButton = notesContent.find('.js-discussion-reply-button:visible');
+ if (replyButton.length) {
+ this.replyToDiscussionNote(replyButton[0]);
+ } else {
+ // In parallel view, the form may not be present in one of the panes
+ noteForm = notesContent.find('.js-discussion-note-form');
+ if (noteForm.length === 0) {
+ addForm = true;
+ }
+ }
}
+ } else if (showReplyInput) {
+ // add a notes row and insert the form
+ row.after(rowCssToAdd);
+ targetRow = row.next();
+ notesContent = targetRow.find(notesContentSelector);
+ addForm = true;
+ } else {
+ const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
+ const isForced = forceShow === true || forceShow === false;
+ const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
+
+ targetRow.toggle(showNow);
+ notesContent.toggle(showNow);
+ }
- form.attr('data-line-code', dataHolder.data('lineCode'));
- form.find('#line_type').val(dataHolder.data('lineType'));
-
- form.find('#note_noteable_type').val(dataHolder.data('noteableType'));
- form.find('#note_noteable_id').val(dataHolder.data('noteableId'));
- form.find('#note_commit_id').val(dataHolder.data('commitId'));
- form.find('#note_type').val(dataHolder.data('noteType'));
-
- // LegacyDiffNote
- form.find('#note_line_code').val(dataHolder.data('lineCode'));
-
- // 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('cancel-text'));
- form.find('.js-note-target-close').remove();
- form.find('.js-note-new-discussion').remove();
- this.setupNoteForm(form);
-
- form
- .removeClass('js-main-target-form')
- .addClass('discussion-form js-discussion-note-form');
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- var $commentBtn = form.find('comment-and-resolve-btn');
- $commentBtn.attr(':discussion-id', `'${discussionID}'`);
-
- gl.diffNotesCompileComponents();
+ if (addForm) {
+ newForm = this.cleanForm(this.formClone.clone());
+ newForm.appendTo(notesContent);
+ // show the form
+ return this.setupDiscussionNoteForm($link, newForm);
+ }
+ }
+
+ /**
+ * Called in response to "cancel" on a diff note form.
+ *
+ * Shows the reply button again.
+ * Removes the form and if necessary it's temporary row.
+ */
+ removeDiscussionNoteForm(form) {
+ var glForm, row;
+ row = form.closest('tr');
+ glForm = form.data('gl-form');
+ 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();
+ if (row.is('.js-temp-notes-holder')) {
+ // remove temporary row for diff lines
+ return row.remove();
+ } else {
+ // only remove the form
+ return form.remove();
+ }
+ }
+
+ cancelDiscussionForm(e) {
+ var form;
+ e.preventDefault();
+ form = $(e.target).closest('.js-discussion-note-form');
+ return this.removeDiscussionNoteForm(form);
+ }
+
+ /**
+ * Called after an attachment file has been selected.
+ *
+ * Updates the file name for the selected attachment.
+ */
+ updateFormAttachment() {
+ var filename, form;
+ form = $(this).closest('form');
+ // get only the basename
+ filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-attachment-filename').text(filename);
+ }
+
+ /**
+ * Called when the tab visibility changes
+ */
+ visibilityChange() {
+ return this.refresh();
+ }
+
+ updateTargetButtons(e) {
+ var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
+ textarea = $(e.target);
+ form = textarea.parents('form');
+ reopenbtn = form.find('.js-note-target-reopen');
+ closebtn = form.find('.js-note-target-close');
+ discardbtn = form.find('.js-note-discard');
+
+ if (textarea.val().trim().length > 0) {
+ reopentext = reopenbtn.attr('data-alternative-text');
+ closetext = closebtn.attr('data-alternative-text');
+ if (reopenbtn.text() !== reopentext) {
+ reopenbtn.text(reopentext);
}
-
- form.find('.js-note-text').focus();
- form
- .find('.js-comment-resolve-button')
- .attr('data-discussion-id', discussionID);
- };
-
- /*
- Called when clicking on the "add a comment" button on the side of a diff line.
-
- Inserts a temporary row for the form below the line.
- Sets up the form and shows it.
- */
-
- Notes.prototype.onAddDiffNote = function(e) {
- e.preventDefault();
- const link = e.currentTarget || e.target;
- const $link = $(link);
- const showReplyInput = !$link.hasClass('js-diff-comment-avatar');
- this.toggleDiffNote({
- target: $link,
- lineType: link.dataset.lineType,
- showReplyInput
- });
- };
-
- Notes.prototype.toggleDiffNote = function({
- 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();
- let targetRow = row;
- if (nextRow.is('.notes_holder')) {
- targetRow = nextRow;
+ if (closebtn.text() !== closetext) {
+ closebtn.text(closetext);
}
-
- 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>';
- // 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>';
+ if (reopenbtn.is(':not(.btn-comment-and-reopen)')) {
+ reopenbtn.addClass('btn-comment-and-reopen');
}
- const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
- let notesContent = targetRow.find(notesContentSelector);
-
- if (hasNotes && showReplyInput) {
- targetRow.show();
- notesContent = targetRow.find(notesContentSelector);
- if (notesContent.length) {
- notesContent.show();
- replyButton = notesContent.find('.js-discussion-reply-button:visible');
- if (replyButton.length) {
- this.replyToDiscussionNote(replyButton[0]);
- } else {
- // In parallel view, the form may not be present in one of the panes
- noteForm = notesContent.find('.js-discussion-note-form');
- if (noteForm.length === 0) {
- addForm = true;
- }
- }
- }
- } else if (showReplyInput) {
- // add a notes row and insert the form
- row.after(rowCssToAdd);
- targetRow = row.next();
- notesContent = targetRow.find(notesContentSelector);
- addForm = true;
- } else {
- const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
- const isForced = forceShow === true || forceShow === false;
- const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
-
- targetRow.toggle(showNow);
- notesContent.toggle(showNow);
+ if (closebtn.is(':not(.btn-comment-and-close)')) {
+ closebtn.addClass('btn-comment-and-close');
}
-
- if (addForm) {
- newForm = this.cleanForm(this.formClone.clone());
- newForm.appendTo(notesContent);
- // show the form
- return this.setupDiscussionNoteForm($link, newForm);
+ if (discardbtn.is(':hidden')) {
+ return discardbtn.show();
}
- };
-
- /*
- Called in response to "cancel" on a diff note form.
-
- Shows the reply button again.
- Removes the form and if necessary it's temporary row.
- */
-
- Notes.prototype.removeDiscussionNoteForm = function(form) {
- var glForm, row;
- row = form.closest('tr');
- glForm = form.data('gl-form');
- 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();
- if (row.is('.js-temp-notes-holder')) {
- // remove temporary row for diff lines
- return row.remove();
- } else {
- // only remove the form
- return form.remove();
+ } else {
+ reopentext = reopenbtn.data('original-text');
+ closetext = closebtn.data('original-text');
+ if (reopenbtn.text() !== reopentext) {
+ reopenbtn.text(reopentext);
}
- };
-
- Notes.prototype.cancelDiscussionForm = function(e) {
- var form;
- e.preventDefault();
- form = $(e.target).closest('.js-discussion-note-form');
- return this.removeDiscussionNoteForm(form);
- };
-
- /*
- Called after an attachment file has been selected.
-
- Updates the file name for the selected attachment.
- */
-
- Notes.prototype.updateFormAttachment = function() {
- var filename, form;
- form = $(this).closest('form');
- // get only the basename
- filename = $(this).val().replace(/^.*[\\\/]/, '');
- return form.find('.js-attachment-filename').text(filename);
- };
-
- /*
- Called when the tab visibility changes
- */
-
- Notes.prototype.visibilityChange = function() {
- return this.refresh();
- };
-
- Notes.prototype.updateTargetButtons = function(e) {
- var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
- textarea = $(e.target);
- form = textarea.parents('form');
- reopenbtn = form.find('.js-note-target-reopen');
- closebtn = form.find('.js-note-target-close');
- discardbtn = form.find('.js-note-discard');
-
- if (textarea.val().trim().length > 0) {
- reopentext = reopenbtn.attr('data-alternative-text');
- closetext = closebtn.attr('data-alternative-text');
- if (reopenbtn.text() !== reopentext) {
- reopenbtn.text(reopentext);
- }
- if (closebtn.text() !== closetext) {
- closebtn.text(closetext);
- }
- if (reopenbtn.is(':not(.btn-comment-and-reopen)')) {
- reopenbtn.addClass('btn-comment-and-reopen');
- }
- if (closebtn.is(':not(.btn-comment-and-close)')) {
- closebtn.addClass('btn-comment-and-close');
- }
- if (discardbtn.is(':hidden')) {
- return discardbtn.show();
- }
- } else {
- reopentext = reopenbtn.data('original-text');
- closetext = closebtn.data('original-text');
- if (reopenbtn.text() !== reopentext) {
- reopenbtn.text(reopentext);
- }
- if (closebtn.text() !== closetext) {
- closebtn.text(closetext);
- }
- if (reopenbtn.is('.btn-comment-and-reopen')) {
- reopenbtn.removeClass('btn-comment-and-reopen');
- }
- if (closebtn.is('.btn-comment-and-close')) {
- closebtn.removeClass('btn-comment-and-close');
- }
- if (discardbtn.is(':visible')) {
- return discardbtn.hide();
- }
+ if (closebtn.text() !== closetext) {
+ closebtn.text(closetext);
}
- };
-
- Notes.prototype.putEditFormInPlace = function($el) {
- var $editForm = $(this.getEditFormSelector($el));
- var $note = $el.closest('.note');
-
- $editForm.insertAfter($note.find('.note-text'));
-
- var $originalContentEl = $note.find('.original-note-content');
- var originalContent = $originalContentEl.text().trim();
- var postUrl = $originalContentEl.data('post-url');
- var targetId = $originalContentEl.data('target-id');
- var targetType = $originalContentEl.data('target-type');
-
- new gl.GLForm($editForm.find('form'), this.enableGFM);
-
- $editForm.find('form')
- .attr('action', postUrl)
- .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-md-write-button').trigger('click');
- $editForm.find('.referenced-users').hide();
- };
-
- Notes.prototype.putConflictEditWarningInPlace = function(noteEntity, $note) {
- 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">
- updated comment
- </a>
- to ensure information is not lost
- </div>`);
- $alert.insertAfter($note.find('.note-text'));
+ if (reopenbtn.is('.btn-comment-and-reopen')) {
+ reopenbtn.removeClass('btn-comment-and-reopen');
}
- };
-
- Notes.prototype.updateNotesCount = function(updateCount) {
- return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
- };
-
- Notes.prototype.toggleCommitList = function(e) {
- const $element = $(e.currentTarget);
- const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
-
- $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
- $closestSystemCommitList.toggleClass('hide-shade');
- };
-
- /**
- Scans system notes with `ul` elements in system note body
- then collapse long commit list pushed by user to make it less
- intrusive.
- */
- Notes.prototype.collapseLongCommitList = function() {
- 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(':', '');
-
- $systemNote.find('.note-header .system-note-message').html(headerMessage);
-
- if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) {
- $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');
- }
- });
- };
-
- Notes.prototype.addFlash = function(...flashParams) {
- this.flashInstance = new Flash(...flashParams);
- };
-
- Notes.prototype.clearFlash = function() {
- if (this.flashInstance && this.flashInstance.flashContainer) {
- this.flashInstance.flashContainer.hide();
- this.flashInstance = null;
+ if (closebtn.is('.btn-comment-and-close')) {
+ closebtn.removeClass('btn-comment-and-close');
}
- };
-
- Notes.prototype.cleanForm = function($form) {
- // Remove JS classes that are not needed here
- $form
- .find('.js-comment-type-dropdown')
- .removeClass('btn-group');
-
- // Remove dropdown
- $form
- .find('.dropdown-menu')
- .remove();
-
- return $form;
- };
-
- /**
- * Check if note does not exists on page
- */
- Notes.isNewNote = function(noteEntity, noteIds) {
- return $.inArray(noteEntity.id, noteIds) === -1;
- };
-
- /**
- * Check if $note already contains the `noteEntity` content
- */
- Notes.isUpdatedNote = function(noteEntity, $note) {
- // 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()
- );
- return sanitizedNoteEntityText !== currentNoteText;
- };
-
- Notes.checkMergeRequestStatus = function() {
- if (gl.utils.getPagePath(1) === 'merge_requests') {
- gl.mrWidget.checkStatus();
+ if (discardbtn.is(':visible')) {
+ return discardbtn.hide();
}
- };
+ }
+ }
+
+ putEditFormInPlace($el) {
+ var $editForm = $(this.getEditFormSelector($el));
+ var $note = $el.closest('.note');
+
+ $editForm.insertAfter($note.find('.note-text'));
+
+ var $originalContentEl = $note.find('.original-note-content');
+ var originalContent = $originalContentEl.text().trim();
+ var postUrl = $originalContentEl.data('post-url');
+ var targetId = $originalContentEl.data('target-id');
+ var targetType = $originalContentEl.data('target-type');
+
+ new gl.GLForm($editForm.find('form'), this.enableGFM);
+
+ $editForm.find('form')
+ .attr('action', postUrl)
+ .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-md-write-button').trigger('click');
+ $editForm.find('.referenced-users').hide();
+ }
+
+ putConflictEditWarningInPlace(noteEntity, $note) {
+ 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">
+ updated comment
+ </a>
+ to ensure information is not lost
+ </div>`);
+ $alert.insertAfter($note.find('.note-text'));
+ }
+ }
- Notes.animateAppendNote = function(noteHtml, $notesList) {
- const $note = $(noteHtml);
+ updateNotesCount(updateCount) {
+ return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
+ }
- $note.addClass('fade-in-full').renderGFM();
- $notesList.append($note);
- return $note;
- };
+ toggleCommitList(e) {
+ const $element = $(e.currentTarget);
+ const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
- Notes.animateUpdateNote = function(noteHtml, $note) {
- const $updatedNote = $(noteHtml);
+ $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
+ $closestSystemCommitList.toggleClass('hide-shade');
+ }
- $updatedNote.addClass('fade-in').renderGFM();
- $note.replaceWith($updatedNote);
- return $updatedNote;
- };
+ /**
+ * Scans system notes with `ul` elements in system note body
+ * then collapse long commit list pushed by user to make it less
+ * intrusive.
+ */
+ collapseLongCommitList() {
+ const systemNotes = $('#notes-list').find('li.system-note').has('ul');
- /**
- * Get data from Form attributes to use for saving/submitting comment.
- */
- Notes.prototype.getFormData = function($form) {
- return {
- formData: $form.serialize(),
- formContent: _.escape($form.find('.js-note-text').val()),
- formAction: $form.attr('action'),
- };
- };
+ $.each(systemNotes, function(index, systemNote) {
+ const $systemNote = $(systemNote);
+ const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', '');
- /**
- * Identify if comment has any quick actions
- */
- Notes.prototype.hasQuickActions = function(formContent) {
- return REGEX_QUICK_ACTIONS.test(formContent);
- };
-
- /**
- * Remove quick actions and leave comment with pure message
- */
- Notes.prototype.stripQuickActions = function(formContent) {
- return formContent.replace(REGEX_QUICK_ACTIONS, '').trim();
- };
+ $systemNote.find('.note-header .system-note-message').html(headerMessage);
- /**
- * Gets appropriate description from quick actions found in provided `formContent`
- */
- Notes.prototype.getQuickActionDescription = function (formContent, availableQuickActions = []) {
- let tempFormContent;
+ if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) {
+ $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');
+ }
+ });
+ }
- // Identify executed quick actions from `formContent`
- const executedCommands = availableQuickActions.filter((command, index) => {
- const commandRegex = new RegExp(`/${command.name}`);
- return commandRegex.test(formContent);
- });
+ addFlash(...flashParams) {
+ this.flashInstance = new Flash(...flashParams);
+ }
- if (executedCommands && executedCommands.length) {
- if (executedCommands.length > 1) {
- tempFormContent = 'Applying multiple commands';
- } else {
- const commandDescription = executedCommands[0].description.toLowerCase();
- tempFormContent = `Applying command to ${commandDescription}`;
- }
+ clearFlash() {
+ if (this.flashInstance && this.flashInstance.flashContainer) {
+ this.flashInstance.flashContainer.hide();
+ this.flashInstance = null;
+ }
+ }
+
+ cleanForm($form) {
+ // Remove JS classes that are not needed here
+ $form
+ .find('.js-comment-type-dropdown')
+ .removeClass('btn-group');
+
+ // Remove dropdown
+ $form
+ .find('.dropdown-menu')
+ .remove();
+
+ return $form;
+ }
+
+ /**
+ * Check if note does not exists on page
+ */
+ static isNewNote(noteEntity, noteIds) {
+ return $.inArray(noteEntity.id, noteIds) === -1;
+ }
+
+ /**
+ * Check if $note already contains the `noteEntity` content
+ */
+ static isUpdatedNote(noteEntity, $note) {
+ // 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()
+ );
+ return sanitizedNoteEntityText !== currentNoteText;
+ }
+
+ static checkMergeRequestStatus() {
+ if (gl.utils.getPagePath(1) === 'merge_requests') {
+ gl.mrWidget.checkStatus();
+ }
+ }
+
+ static animateAppendNote(noteHtml, $notesList) {
+ const $note = $(noteHtml);
+
+ $note.addClass('fade-in-full').renderGFM();
+ $notesList.append($note);
+ return $note;
+ }
+
+ static animateUpdateNote(noteHtml, $note) {
+ const $updatedNote = $(noteHtml);
+
+ $updatedNote.addClass('fade-in').renderGFM();
+ $note.replaceWith($updatedNote);
+ return $updatedNote;
+ }
+
+ /**
+ * Get data from Form attributes to use for saving/submitting comment.
+ */
+ getFormData($form) {
+ return {
+ formData: $form.serialize(),
+ formContent: _.escape($form.find('.js-note-text').val()),
+ formAction: $form.attr('action'),
+ };
+ }
+
+ /**
+ * Identify if comment has any quick actions
+ */
+ hasQuickActions(formContent) {
+ return REGEX_QUICK_ACTIONS.test(formContent);
+ }
+
+ /**
+ * Remove quick actions and leave comment with pure message
+ */
+ stripQuickActions(formContent) {
+ return formContent.replace(REGEX_QUICK_ACTIONS, '').trim();
+ }
+
+ /**
+ * Gets appropriate description from quick actions found in provided `formContent`
+ */
+ getQuickActionDescription(formContent, availableQuickActions = []) {
+ let tempFormContent;
+
+ // Identify executed quick actions from `formContent`
+ const executedCommands = availableQuickActions.filter((command, index) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(formContent);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ tempFormContent = 'Applying multiple commands';
} else {
- tempFormContent = 'Applying command';
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ tempFormContent = `Applying command to ${commandDescription}`;
}
+ } else {
+ tempFormContent = 'Applying command';
+ }
- return tempFormContent;
- };
-
- /**
- * Create placeholder note DOM element populated with comment body
- * that we will show while comment is being posted.
- * Once comment is _actually_ posted on server, we will have final element
- * in response that we will show in place of this temporary element.
- */
- Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
- const discussionClass = isDiscussionNote ? 'discussion' : '';
- const $tempNote = $(
- `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
- <div class="timeline-entry-inner">
- <div class="timeline-icon">
- <a href="/${currentUsername}">
- <img class="avatar s40" src="${currentUserAvatar}">
- </a>
- </div>
- <div class="timeline-content ${discussionClass}">
- <div class="note-header">
- <div class="note-header-info">
- <a href="/${currentUsername}">
- <span class="hidden-xs">${currentUserFullname}</span>
- <span class="note-headline-light">@${currentUsername}</span>
- </a>
- </div>
+ return tempFormContent;
+ }
+
+ /**
+ * Create placeholder note DOM element populated with comment body
+ * that we will show while comment is being posted.
+ * 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 }) {
+ const discussionClass = isDiscussionNote ? 'discussion' : '';
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <a href="/${currentUsername}">
+ <img class="avatar s40" src="${currentUserAvatar}">
+ </a>
+ </div>
+ <div class="timeline-content ${discussionClass}">
+ <div class="note-header">
+ <div class="note-header-info">
+ <a href="/${currentUsername}">
+ <span class="hidden-xs">${currentUserFullname}</span>
+ <span class="note-headline-light">@${currentUsername}</span>
+ </a>
+ </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>${formContent}</p>
</div>
- <div class="note-body">
- <div class="note-text">
- <p>${formContent}</p>
- </div>
- </div>
- </div>
+ </div>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ }
+
+ /**
+ * Create Placeholder System Note DOM element populated with quick action description
+ */
+ createPlaceholderSystemNote({ formContent, uniqueId }) {
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <i>${formContent}</i>
</div>
- </li>`
- );
-
- return $tempNote;
- };
-
- /**
- * Create Placeholder System Note DOM element populated with quick action description
- */
- Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) {
- const $tempNote = $(
- `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
- <div class="timeline-entry-inner">
- <div class="timeline-content">
- <i>${formContent}</i>
- </div>
- </div>
- </li>`
- );
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ }
+
+ /**
+ * This method does following tasks step-by-step whenever a new comment
+ * is submitted by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
+ * 3) Build temporary placeholder element (using `createPlaceholderNote`)
+ * 4) Show placeholder note on UI
+ * 5) Perform network request to submit the note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Remove placeholder element
+ * 2. Show submitted Note element
+ * 3. Perform post-submit errands
+ * a. Mark discussion as resolved if comment submission was for resolve.
+ * b. Reset comment form to original state.
+ * b) If request failed
+ * 1. Remove placeholder element
+ * 2. Show error Flash message about failure
+ */
+ postComment(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ 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 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 } = this.getFormData($form);
+ let noteUniqueId;
+ let systemNoteUniqueId;
+ let hasQuickActions = false;
+ let $notesContainer;
+ let tempFormContent;
+
+ // Get reference to notes container based on type of comment
+ if (isDiscussionForm) {
+ $notesContainer = $form.parent('.discussion-notes').find('.notes');
+ } else if (isMainForm) {
+ $notesContainer = $('ul.main-notes-list');
+ }
- return $tempNote;
- };
+ // If comment is to resolve discussion, disable submit buttons while
+ // comment posting is finished.
+ if (isDiscussionResolve) {
+ $submitBtn.disable();
+ $form.find('.js-comment-submit-button').disable();
+ }
- /**
- * This method does following tasks step-by-step whenever a new comment
- * is submitted by user (both main thread comments as well as discussion comments).
- *
- * 1) Get Form metadata
- * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
- * 3) Build temporary placeholder element (using `createPlaceholderNote`)
- * 4) Show placeholder note on UI
- * 5) Perform network request to submit the note using `gl.utils.ajaxPost`
- * a) If request is successfully completed
- * 1. Remove placeholder element
- * 2. Show submitted Note element
- * 3. Perform post-submit errands
- * a. Mark discussion as resolved if comment submission was for resolve.
- * b. Reset comment form to original state.
- * b) If request failed
- * 1. Remove placeholder element
- * 2. Show error Flash message about failure
- */
- Notes.prototype.postComment = function(e) {
- e.preventDefault();
-
- // Get Form metadata
- 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 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 } = this.getFormData($form);
- let noteUniqueId;
- let systemNoteUniqueId;
- let hasQuickActions = false;
- let $notesContainer;
- let tempFormContent;
-
- // Get reference to notes container based on type of comment
- if (isDiscussionForm) {
- $notesContainer = $form.parent('.discussion-notes').find('.notes');
- } else if (isMainForm) {
- $notesContainer = $('ul.main-notes-list');
- }
+ tempFormContent = formContent;
+ if (this.hasQuickActions(formContent)) {
+ tempFormContent = this.stripQuickActions(formContent);
+ hasQuickActions = true;
+ }
- // If comment is to resolve discussion, disable submit buttons while
- // comment posting is finished.
- if (isDiscussionResolve) {
- $submitBtn.disable();
- $form.find('.js-comment-submit-button').disable();
- }
+ // 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,
+ }));
+ }
- tempFormContent = formContent;
- if (this.hasQuickActions(formContent)) {
- tempFormContent = this.stripQuickActions(formContent);
- hasQuickActions = true;
- }
+ // 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,
+ }));
+ }
- // 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,
- }));
+ // Clear the form textarea
+ if ($notesContainer.length) {
+ if (isMainForm) {
+ this.resetMainTargetForm(e);
+ } else if (isDiscussionForm) {
+ this.removeDiscussionNoteForm($form);
}
+ }
- // 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,
- }));
- }
+ /* eslint-disable promise/catch-or-return */
+ // Make request to submit comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! remove placeholder
+ $notesContainer.find(`#${noteUniqueId}`).remove();
- // Clear the form textarea
- if ($notesContainer.length) {
- if (isMainForm) {
- this.resetMainTargetForm(e);
- } else if (isDiscussionForm) {
- this.removeDiscussionNoteForm($form);
+ // Reset cached commands list when command is applied
+ if (hasQuickActions) {
+ $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
}
- }
-
- /* eslint-disable promise/catch-or-return */
- // Make request to submit comment on server
- gl.utils.ajaxPost(formAction, formData)
- .then((note) => {
- // Submission successful! remove placeholder
- $notesContainer.find(`#${noteUniqueId}`).remove();
- // Reset cached commands list when command is applied
- if (hasQuickActions) {
- $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
- }
-
- // Clear previous form errors
- this.clearFlashWrapper();
+ // Clear previous form errors
+ this.clearFlashWrapper();
- // Check if this was discussion comment
- if (isDiscussionForm) {
- // Remove flash-container
- $notesContainer.find('.flash-container').remove();
+ // Check if this was discussion comment
+ if (isDiscussionForm) {
+ // Remove flash-container
+ $notesContainer.find('.flash-container').remove();
- // If comment intends to resolve discussion, do the same.
- if (isDiscussionResolve) {
- $form
- .attr('data-discussion-id', $submitBtn.data('discussion-id'))
- .attr('data-resolve-all', 'true')
- .attr('data-project-path', $submitBtn.data('project-path'));
- }
+ // If comment intends to resolve discussion, do the same.
+ if (isDiscussionResolve) {
+ $form
+ .attr('data-discussion-id', $submitBtn.data('discussion-id'))
+ .attr('data-resolve-all', 'true')
+ .attr('data-project-path', $submitBtn.data('project-path'));
+ }
- // Show final note element on UI
- this.addDiscussionNote($form, note, $notesContainer.length === 0);
+ // Show final note element on UI
+ this.addDiscussionNote($form, note, $notesContainer.length === 0);
- // append flash-container to the Notes list
- if ($notesContainer.length) {
- $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
- }
- } 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);
+ // append flash-container to the Notes list
+ if ($notesContainer.length) {
+ $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
}
+ } 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);
+ }
- if (note.commands_changes) {
- this.handleQuickActions(note);
- }
+ if (note.commands_changes) {
+ this.handleQuickActions(note);
+ }
- $form.trigger('ajax:success', [note]);
- }).fail(() => {
- // Submission failed, remove placeholder note and show Flash error message
- $notesContainer.find(`#${noteUniqueId}`).remove();
+ $form.trigger('ajax:success', [note]);
+ }).fail(() => {
+ // Submission failed, remove placeholder note and show Flash error message
+ $notesContainer.find(`#${noteUniqueId}`).remove();
- if (hasQuickActions) {
- $notesContainer.find(`#${systemNoteUniqueId}`).remove();
- }
+ if (hasQuickActions) {
+ $notesContainer.find(`#${systemNoteUniqueId}`).remove();
+ }
- // Show form again on UI on failure
- if (isDiscussionForm && $notesContainer.length) {
- const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
- this.replyToDiscussionNote(replyButton[0]);
- $form = $notesContainer.parent().find('form');
- }
+ // Show form again on UI on failure
+ if (isDiscussionForm && $notesContainer.length) {
+ const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
+ this.replyToDiscussionNote(replyButton[0]);
+ $form = $notesContainer.parent().find('form');
+ }
- $form.find('.js-note-text').val(formContent);
- this.reenableTargetFormSubmitButton(e);
- this.addNoteError($form);
- });
+ $form.find('.js-note-text').val(formContent);
+ this.reenableTargetFormSubmitButton(e);
+ this.addNoteError($form);
+ });
- return $closeBtn.text($closeBtn.data('original-text'));
- };
+ return $closeBtn.text($closeBtn.data('original-text'));
+ }
+
+ /**
+ * This method does following tasks step-by-step whenever an existing comment
+ * is updated by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Update note element with new content
+ * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Show submitted Note element
+ * b) If request failed
+ * 1. Revert Note element to original content
+ * 2. Show error Flash message about failure
+ */
+ updateComment(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ const $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const $editingNote = $form.parents('.note.is-editing');
+ const $noteBody = $editingNote.find('.js-task-list-container');
+ const $noteBodyText = $noteBody.find('.note-text');
+ const { formData, formContent, formAction } = this.getFormData($form);
+
+ // Cache original comment content
+ const cachedNoteBodyText = $noteBodyText.html();
+
+ // Show updated comment content temporarily
+ $noteBodyText.html(_.escape(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>');
+
+ /* eslint-disable promise/catch-or-return */
+ // Make request to update comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! render final note element
+ this.updateNote(note, $editingNote);
+ })
+ .fail(() => {
+ // Submission failed, revert back to original note
+ $noteBodyText.html(_.escape(cachedNoteBodyText));
+ $editingNote.removeClass('being-posted fade-in');
+ $editingNote.find('.fa.fa-spinner').remove();
+
+ // Show Flash message about failure
+ this.updateNoteError();
+ });
- /**
- * This method does following tasks step-by-step whenever an existing comment
- * is updated by user (both main thread comments as well as discussion comments).
- *
- * 1) Get Form metadata
- * 2) Update note element with new content
- * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
- * a) If request is successfully completed
- * 1. Show submitted Note element
- * b) If request failed
- * 1. Revert Note element to original content
- * 2. Show error Flash message about failure
- */
- Notes.prototype.updateComment = function(e) {
- e.preventDefault();
-
- // Get Form metadata
- const $submitBtn = $(e.target);
- const $form = $submitBtn.parents('form');
- const $closeBtn = $form.find('.js-note-target-close');
- const $editingNote = $form.parents('.note.is-editing');
- const $noteBody = $editingNote.find('.js-task-list-container');
- const $noteBodyText = $noteBody.find('.note-text');
- const { formData, formContent, formAction } = this.getFormData($form);
-
- // Cache original comment content
- const cachedNoteBodyText = $noteBodyText.html();
-
- // Show updated comment content temporarily
- $noteBodyText.html(_.escape(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>');
-
- /* eslint-disable promise/catch-or-return */
- // Make request to update comment on server
- gl.utils.ajaxPost(formAction, formData)
- .then((note) => {
- // Submission successful! render final note element
- this.updateNote(note, $editingNote);
- })
- .fail(() => {
- // Submission failed, revert back to original note
- $noteBodyText.html(_.escape(cachedNoteBodyText));
- $editingNote.removeClass('being-posted fade-in');
- $editingNote.find('.fa.fa-spinner').remove();
-
- // Show Flash message about failure
- this.updateNoteError();
- });
-
- return $closeBtn.text($closeBtn.data('original-text'));
- };
+ return $closeBtn.text($closeBtn.data('original-text'));
+ }
+}
- return Notes;
- })();
-}).call(window);
+window.Notes = Notes;
diff --git a/app/assets/javascripts/prometheus_metrics/constants.js b/app/assets/javascripts/prometheus_metrics/constants.js
new file mode 100644
index 00000000000..50f1248456e
--- /dev/null
+++ b/app/assets/javascripts/prometheus_metrics/constants.js
@@ -0,0 +1,5 @@
+export default {
+ EMPTY: 'empty',
+ LOADING: 'loading',
+ LIST: 'list',
+};
diff --git a/app/assets/javascripts/prometheus_metrics/index.js b/app/assets/javascripts/prometheus_metrics/index.js
new file mode 100644
index 00000000000..a0c43c5abe1
--- /dev/null
+++ b/app/assets/javascripts/prometheus_metrics/index.js
@@ -0,0 +1,6 @@
+import PrometheusMetrics from './prometheus_metrics';
+
+$(() => {
+ const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ prometheusMetrics.loadActiveMetrics();
+});
diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
new file mode 100644
index 00000000000..ef4d6df5138
--- /dev/null
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -0,0 +1,109 @@
+import PANEL_STATE from './constants';
+
+export default class PrometheusMetrics {
+ constructor(wrapperSelector) {
+ this.backOffRequestCounter = 0;
+
+ this.$wrapper = $(wrapperSelector);
+
+ this.$monitoredMetricsPanel = this.$wrapper.find('.js-panel-monitored-metrics');
+ this.$monitoredMetricsCount = this.$monitoredMetricsPanel.find('.js-monitored-count');
+ this.$monitoredMetricsLoading = this.$monitoredMetricsPanel.find('.js-loading-metrics');
+ this.$monitoredMetricsEmpty = this.$monitoredMetricsPanel.find('.js-empty-metrics');
+ this.$monitoredMetricsList = this.$monitoredMetricsPanel.find('.js-metrics-list');
+
+ this.$missingEnvVarPanel = this.$wrapper.find('.js-panel-missing-env-vars');
+ this.$panelToggle = this.$missingEnvVarPanel.find('.js-panel-toggle');
+ this.$missingEnvVarMetricCount = this.$missingEnvVarPanel.find('.js-env-var-count');
+ this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list');
+
+ this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('active-metrics');
+
+ this.$panelToggle.on('click', e => this.handlePanelToggle(e));
+ }
+
+ /* eslint-disable class-methods-use-this */
+ handlePanelToggle(e) {
+ const $toggleBtn = $(e.currentTarget);
+ const $currentPanelBody = $toggleBtn.closest('.panel').find('.panel-body');
+ $currentPanelBody.toggleClass('hidden');
+ if ($toggleBtn.hasClass('fa-caret-down')) {
+ $toggleBtn.removeClass('fa-caret-down').addClass('fa-caret-right');
+ } else {
+ $toggleBtn.removeClass('fa-caret-right').addClass('fa-caret-down');
+ }
+ }
+
+ showMonitoringMetricsPanelState(stateName) {
+ switch (stateName) {
+ case PANEL_STATE.LOADING:
+ this.$monitoredMetricsLoading.removeClass('hidden');
+ this.$monitoredMetricsEmpty.addClass('hidden');
+ this.$monitoredMetricsList.addClass('hidden');
+ break;
+ case PANEL_STATE.LIST:
+ this.$monitoredMetricsLoading.addClass('hidden');
+ this.$monitoredMetricsEmpty.addClass('hidden');
+ this.$monitoredMetricsList.removeClass('hidden');
+ break;
+ default:
+ this.$monitoredMetricsLoading.addClass('hidden');
+ this.$monitoredMetricsEmpty.removeClass('hidden');
+ this.$monitoredMetricsList.addClass('hidden');
+ break;
+ }
+ }
+
+ populateActiveMetrics(metrics) {
+ let totalMonitoredMetrics = 0;
+ let totalMissingEnvVarMetrics = 0;
+
+ metrics.forEach((metric) => {
+ this.$monitoredMetricsList.append(`<li>${metric.group}<span class="badge">${metric.active_metrics}</span></li>`);
+ totalMonitoredMetrics += metric.active_metrics;
+ if (metric.metrics_missing_requirements > 0) {
+ this.$missingEnvVarMetricsList.append(`<li>${metric.group}</li>`);
+ totalMissingEnvVarMetrics += 1;
+ }
+ });
+
+ this.$monitoredMetricsCount.text(totalMonitoredMetrics);
+ this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+
+ if (totalMissingEnvVarMetrics > 0) {
+ this.$missingEnvVarPanel.removeClass('hidden');
+ this.$missingEnvVarPanel.find('.flash-container').off('click');
+ this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
+ }
+ }
+
+ loadActiveMetrics() {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
+ gl.utils.backOff((next, stop) => {
+ $.getJSON(this.activeMetricsEndpoint)
+ .done((res) => {
+ if (res && res.success) {
+ stop(res);
+ } else {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ if (this.backOffRequestCounter < 3) {
+ next();
+ } else {
+ stop(res);
+ }
+ }
+ })
+ .fail(stop);
+ })
+ .then((res) => {
+ if (res && res.data && res.data.length) {
+ this.populateActiveMetrics(res.data);
+ } else {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ }
+ })
+ .catch(() => {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ });
+ }
+}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index da7c0c5a36c..322162afdb8 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -10,8 +10,6 @@ import Cookies from 'js-cookie';
this.$sidebarInner = this.sidebar.find('.issuable-sidebar');
this.$navGitlab = $('.navbar-gitlab');
- this.$layoutNav = $('.layout-nav');
- this.$subScroll = $('.sub-nav-scroll');
this.$rightSidebar = $('.js-right-sidebar');
this.removeListeners();
@@ -215,7 +213,7 @@ import Cookies from 'js-cookie';
};
Sidebar.prototype.setSidebarHeight = function() {
- const $navHeight = this.$navGitlab.outerHeight() + this.$layoutNav.outerHeight() + (this.$subScroll ? this.$subScroll.outerHeight() : 0);
+ const $navHeight = this.$navGitlab.outerHeight();
const diff = $navHeight - $(window).scrollTop();
if (diff > 0) {
this.$rightSidebar.outerHeight($(window).height() - diff);
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index ec45253e50b..46efdcf4202 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -643,7 +643,7 @@ UsersSelect.prototype.formatResult = function(user) {
} else {
avatar = gon.default_avatar_url;
}
- return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>";
+ return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar avatar-inline s32' src='" + avatar + "'></div> <div class='user-name dropdown-menu-user-full-name'>" + user.name + "</div> <div class='user-username dropdown-menu-user-username'>" + ("@" + user.username || "") + "</div> </div>";
};
UsersSelect.prototype.formatSelection = function(user) {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index cba890ce831..4f54ca24940 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -395,6 +395,11 @@
.dropdown-menu-align-right {
left: auto;
right: 0;
+ margin-top: -5px;
+
+ @media (max-width: $screen-xs-max) {
+ left: 0;
+ }
}
.dropdown-menu-selectable {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index da03e4f5b5e..31220ab438e 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -12,6 +12,12 @@
&.readme-holder {
margin: $gl-padding 0;
+
+ &.limited-width-container .file-content {
+ max-width: $limited-layout-width-sm;
+ margin-left: auto;
+ margin-right: auto;
+ }
}
table {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index d8645afb7da..94986badaf9 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -346,6 +346,7 @@ header {
width: auto;
min-width: 140px;
margin-top: -5px;
+ left: auto;
.current-user {
padding: 5px 18px;
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 49bff23452d..4a9d41b4fda 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -53,7 +53,7 @@ body {
}
&.limit-container-width-sm {
- max-width: 790px;
+ max-width: $limited-layout-width-sm;
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 13c9c6c9fb3..476427c1593 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -161,6 +161,7 @@ $progress-color: #c0392b;
$header-height: 50px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
+$limited-layout-width-sm: 790px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
$border-radius-default: 3px;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 89bd437b362..1046ebfa2e2 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -83,6 +83,7 @@
.avatar {
float: none;
+ margin-right: 0;
}
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 20f2eec9af5..0d1a360d12c 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -11,7 +11,9 @@
.commit-box,
.info-well,
.commit-ci-menu,
- .files-changed {
+ .files-changed,
+ .limited-header-width,
+ .limited-width-notes {
@extend .fixed-width-container;
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 4be0e133b69..f21005895e4 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -136,10 +136,6 @@
width: 250px;
}
- @media (min-width: $screen-md-min) {
- width: 350px;
- }
-
&.input-short {
@media (min-width: $screen-md-min) {
width: 170px;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 2dc7f73a295..59e0624d94e 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -419,7 +419,7 @@
.commit {
margin: 0;
- padding: 10px 0;
+ padding: 10px;
list-style: none;
&:hover {
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index a85ba3a5955..9637d26e56d 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -133,7 +133,7 @@
overflow: hidden;
display: inline-block;
white-space: nowrap;
- vertical-align: top;
+ vertical-align: middle;
text-overflow: ellipsis;
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 7697a1b1c58..d69a8e0995c 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -126,3 +126,66 @@
margin-left: 5px;
}
}
+
+.prometheus-metrics-monitoring {
+ .panel {
+ .panel-toggle {
+ width: 14px;
+ }
+
+ .badge {
+ font-size: inherit;
+ }
+
+ .panel-heading .badge-count {
+ color: $white-light;
+ background: $common-gray-dark;
+ }
+
+ .panel-body {
+ padding: 0;
+ }
+
+ .flash-container {
+ margin-bottom: 0;
+ cursor: default;
+
+ .flash-notice {
+ border-radius: 0;
+ }
+ }
+ }
+
+ .loading-metrics,
+ .empty-metrics {
+ padding: 30px 10px;
+
+ p,
+ .btn {
+ margin-top: 10px;
+ margin-bottom: 0;
+ }
+ }
+
+ .loading-metrics .metrics-load-spinner {
+ color: $loading-color;
+ }
+
+ .metrics-list {
+ margin-bottom: 0;
+
+ li {
+ padding: $gl-padding;
+
+ .badge {
+ margin-left: 5px;
+ background: $badge-bg;
+ }
+ }
+
+ /* Ensure we don't add border if there's only single li */
+ li + li {
+ border-top: 1px solid $border-color;
+ }
+ }
+}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 91694ebcd1d..824ce845706 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -40,6 +40,10 @@ class ApplicationController < ActionController::Base
render_404
end
+ rescue_from(ActionController::UnknownFormat) do
+ render_404
+ end
+
rescue_from Gitlab::Access::AccessDeniedError do |exception|
render_403
end
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 6644deb49c9..47c312ffddf 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -22,6 +22,22 @@ class Projects::DeploymentsController < Projects::ApplicationController
render_404
end
+ def additional_metrics
+ return render_404 unless deployment.has_additional_metrics?
+
+ respond_to do |format|
+ format.json do
+ metrics = deployment.additional_metrics
+
+ if metrics.any?
+ render json: metrics
+ else
+ head :no_content
+ end
+ end
+ end
+ end
+
private
def deployment
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index efe83776834..f88a1ffd1e9 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -129,6 +129,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def additional_metrics
+ respond_to do |format|
+ format.json do
+ additional_metrics = environment.additional_metrics || {}
+
+ render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
diff --git a/app/controllers/projects/prometheus_controller.rb b/app/controllers/projects/prometheus_controller.rb
new file mode 100644
index 00000000000..507468d7102
--- /dev/null
+++ b/app/controllers/projects/prometheus_controller.rb
@@ -0,0 +1,24 @@
+class Projects::PrometheusController < Projects::ApplicationController
+ before_action :authorize_read_project!
+ before_action :require_prometheus_metrics!
+
+ def active_metrics
+ respond_to do |format|
+ format.json do
+ matched_metrics = project.prometheus_service.matched_metrics || {}
+
+ if matched_metrics.any?
+ render json: matched_metrics
+ else
+ head :no_content
+ end
+ end
+ end
+ end
+
+ private
+
+ def require_prometheus_metrics!
+ render_404 unless project.prometheus_service.present?
+ end
+end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index b4c074bc69c..3da5508aefd 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -41,7 +41,7 @@ class IssuesFinder < IssuableFinder
def self.not_restricted_by_confidentiality(user)
return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
- return Issue.all if user.admin?
+ return Issue.all if user.full_private_access?
Issue.where('
issues.confidential IS NOT TRUE
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index c003b01e226..eb45241615f 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -15,7 +15,7 @@ module GroupsHelper
@has_group_title = true
full_title = ''
- group.ancestors.each do |parent|
+ group.ancestors.reverse.each do |parent|
full_title += link_to(simple_sanitize(parent.name), group_path(parent), class: 'group-path hidable')
full_title += '<span class="hidable"> / </span>'.html_safe
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 9c623c9ba7c..b5f54d3e154 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -4,4 +4,14 @@ module UsersHelper
title: user.email,
class: 'has-tooltip commit-committer-link')
end
+
+ def user_email_help_text(user)
+ return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present?
+
+ confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
+
+ h('Please click the link in the confirmation email before continuing. It was sent to ') +
+ content_tag(:strong) { user.unconfirmed_email } + h('.') +
+ content_tag(:p) { confirmation_link }
+ end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 32cfa935aa7..056c49e7162 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -114,6 +114,17 @@ class Deployment < ActiveRecord::Base
project.monitoring_service.deployment_metrics(self)
end
+ def has_additional_metrics?
+ project.prometheus_service.present?
+ end
+
+ def additional_metrics
+ return {} unless project.prometheus_service.present?
+
+ metrics = project.prometheus_service.additional_deployment_metrics(self)
+ metrics&.merge(deployment_time: created_at.to_i) || {}
+ end
+
private
def ref_path
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 781cba76e3c..7ad36f1d80c 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -157,6 +157,16 @@ class Environment < ActiveRecord::Base
project.monitoring_service.environment_metrics(self) if has_metrics?
end
+ def has_additional_metrics?
+ project.prometheus_service.present? && available? && last_deployment.present?
+ end
+
+ def additional_metrics
+ if has_additional_metrics?
+ project.prometheus_service.additional_environment_metrics(self)
+ end
+ end
+
# An environment name is not necessarily suitable for use in URLs, DNS
# or other third-party contexts, so provide a slugified version. A slug has
# the following properties:
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index dde2a11440d..48edd0738ee 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -90,7 +90,7 @@ class ProjectFeature < ActiveRecord::Base
when DISABLED
false
when PRIVATE
- user && (project.team.member?(user) || user.admin?)
+ user && (project.team.member?(user) || user.full_private_access?)
when ENABLED
true
else
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 110b8bc209b..c9df678f97e 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -28,17 +28,6 @@ class PrometheusService < MonitoringService
'Prometheus monitoring'
end
- def help
- <<-MD.strip_heredoc
- Retrieves the Kubernetes node metrics `container_cpu_usage_seconds_total`
- and `container_memory_usage_bytes` from the configured Prometheus server.
-
- If you are not using [Auto-Deploy](https://docs.gitlab.com/ee/ci/autodeploy/index.html)
- or have set up your own Prometheus server, an `environment` label is required on each metric to
- [identify the Environment](https://docs.gitlab.com/ce/user/project/integrations/prometheus.html#metrics-and-labels).
- MD
- end
-
def self.to_param
'prometheus'
end
@@ -50,6 +39,7 @@ class PrometheusService < MonitoringService
name: 'api_url',
title: 'API URL',
placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/',
+ help: 'By default, Prometheus listens on ‘http://localhost:9090’. It’s not recommended to change the default address and port as this might affect or conflict with other services running on the GitLab server.',
required: true
}
]
@@ -65,23 +55,34 @@ class PrometheusService < MonitoringService
end
def environment_metrics(environment)
- with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &:itself)
+ with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &method(:rename_data_to_metrics))
end
def deployment_metrics(deployment)
- metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &:itself)
+ metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &method(:rename_data_to_metrics))
metrics&.merge(deployment_time: created_at.to_i) || {}
end
+ def additional_environment_metrics(environment)
+ with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery.name, environment.id, &:itself)
+ end
+
+ def additional_deployment_metrics(deployment)
+ with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.id, &:itself)
+ end
+
+ def matched_metrics
+ with_reactive_cache(Gitlab::Prometheus::Queries::MatchedMetricsQuery.name, &:itself)
+ end
+
# Cache metrics for specific environment
def calculate_reactive_cache(query_class_name, *args)
return unless active? && project && !project.pending_delete?
- metrics = Kernel.const_get(query_class_name).new(client).query(*args)
-
+ data = Kernel.const_get(query_class_name).new(client).query(*args)
{
success: true,
- metrics: metrics,
+ data: data,
last_update: Time.now.utc
}
rescue Gitlab::PrometheusError => err
@@ -91,4 +92,11 @@ class PrometheusService < MonitoringService
def client
@prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url)
end
+
+ private
+
+ def rename_data_to_metrics(metrics)
+ metrics[:metrics] = metrics.delete :data
+ metrics
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 954a30155f7..9971e43146a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -984,6 +984,12 @@ class User < ActiveRecord::Base
self.admin = (new_level == 'admin')
end
+ # Does the user have access to all private groups & projects?
+ # Overridden in EE to also check auditor?
+ def full_private_access?
+ admin?
+ end
+
def update_two_factor_requirement
periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 8fe0bd149f3..45e39252e16 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -18,5 +18,4 @@
- if current_user
To see all merge requests you should visit #{link_to 'dashboard', merge_requests_dashboard_path} page.
- .prepend-top-default
- = render 'shared/merge_requests'
+ = render 'shared/merge_requests'
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index c7302414386..969c423b032 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -4,7 +4,7 @@
= icon('caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
- - if @group
+ - if @group&.persisted?
- create_group_project = can?(current_user, :create_projects, @group)
- create_group_subgroup = can?(current_user, :create_subgroup, @group)
- if create_group_project || create_group_subgroup
@@ -18,7 +18,7 @@
%li.divider
%li.dropdown-bold-header GitLab
- - if @project && @project.persisted?
+ - if @project&.persisted?
- create_project_issue = can?(current_user, :create_issue, @project)
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- create_project_snippet = can?(current_user, :create_project_snippet, @project)
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 15672289c65..087ae778b0f 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,6 +1,6 @@
= render 'profiles/head'
-= form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default' }, authenticity_token: true do |f|
= form_errors(@user)
.row
@@ -11,11 +11,11 @@
- if @user.avatar?
You can change your avatar here
- if gravatar_enabled?
- or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host}
+ or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host}
- else
You can upload an avatar here
- if gravatar_enabled?
- or change it at #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host}
+ or change it at #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host}
.col-lg-9
.clearfix.avatar-image.append-bottom-default
= link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
@@ -26,12 +26,12 @@
%a.btn.js-choose-user-avatar-button
Browse file...
%span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen
- = f.file_field :avatar, class: "js-user-avatar-input hidden", accept: "image/*"
+ = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.help-block
The maximum file size allowed is 200KB.
- if @user.avatar?
%hr
- = link_to 'Remove avatar', profile_avatar_path, data: { confirm: "Avatar will be removed. Are you sure?" }, method: :delete, class: "btn btn-gray"
+ = link_to 'Remove avatar', profile_avatar_path, data: { confirm: 'Avatar will be removed. Are you sure?' }, method: :delete, class: 'btn btn-gray'
%hr
.row
.col-lg-3.profile-settings-sidebar
@@ -43,91 +43,50 @@
Some options are unavailable for LDAP accounts
.col-lg-9
.row
- .form-group.col-md-9
- = f.label :name, class: "label-light"
- = f.text_field :name, class: "form-control", required: true
- %span.help-block Enter your name, so people you know can recognize you.
+ = f.text_field :name, required: true, wrapper: { class: 'col-md-9' },
+ help: 'Enter your name, so people you know can recognize you.'
+ = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- .form-group.col-md-3
- = f.label :id, class: 'label-light' do
- User ID
- = f.text_field :id, class: 'form-control', readonly: true
-
-
- .form-group
- = f.label :email, class: "label-light"
- - if @user.external_email?
- = f.text_field :email, class: "form-control", required: true, readonly: true
- %span.help-block.light
- Your email address was automatically set based on your #{email_provider_label} account.
- - else
- - if @user.temp_oauth_email?
- = f.text_field :email, class: "form-control", required: true, value: nil
- - else
- = f.text_field :email, class: "form-control", required: true
- - if @user.unconfirmed_email.present?
- %span.help-block
- Please click the link in the confirmation email before continuing. It was sent to
- = succeed "." do
- %strong= @user.unconfirmed_email
- %p
- = link_to "Resend confirmation e-mail", user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
-
- - else
- %span.help-block We also use email for avatar detection if no avatar is uploaded.
- .form-group
- = f.label :public_email, class: "label-light"
- = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2"
- %span.help-block This email will be displayed on your public profile.
- .form-group
- = f.label :preferred_language, class: "label-light"
- = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
- {}, class: "select2"
- %span.help-block This feature is experimental and translations are not complete yet.
- .form-group
- = f.label :skype, class: "label-light"
- = f.text_field :skype, class: "form-control"
- .form-group
- = f.label :linkedin, class: "label-light"
- = f.text_field :linkedin, class: "form-control"
- .form-group
- = f.label :twitter, class: "label-light"
- = f.text_field :twitter, class: "form-control"
- .form-group
- = f.label :website_url, 'Website', class: "label-light"
- = f.text_field :website_url, class: "form-control"
- .form-group
- = f.label :location, 'Location', class: "label-light"
- = f.text_field :location, class: "form-control"
- .form-group
- = f.label :organization, 'Organization', class: "label-light"
- = f.text_field :organization, class: "form-control"
- .form-group
- = f.label :bio, class: "label-light"
- = f.text_area :bio, rows: 4, class: "form-control", maxlength: 250
- %span.help-block Tell us about yourself in fewer than 250 characters.
+ - if @user.external_email?
+ = f.text_field :email, required: true, readonly: true, help: 'Your email address was automatically set based on your #{email_provider_label} account.'
+ - else
+ = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?),
+ help: user_email_help_text(@user)
+ = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
+ { help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' },
+ control_class: 'select2'
+ = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
+ { help: 'This feature is experimental and translations are not complete yet.' },
+ control_class: 'select2'
+ = f.text_field :skype
+ = f.text_field :linkedin
+ = f.text_field :twitter
+ = f.text_field :website_url, label: 'Website'
+ = f.text_field :location
+ = f.text_field :organization
+ = f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.'
.prepend-top-default.append-bottom-default
- = f.submit 'Update profile settings', class: "btn btn-success"
- = link_to "Cancel", user_path(current_user), class: "btn btn-cancel"
+ = f.submit 'Update profile settings', class: 'btn btn-success'
+ = link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel'
.modal.modal-profile-crop
.modal-dialog
.modal-content
.modal-header
- %button.close{ :type => "button", :'data-dismiss' => "modal" }
+ %button.close{ type: 'button', 'data-dismiss': 'modal' }
%span
&times;
%h4.modal-title
Position and size your new avatar
.modal-body
.profile-crop-image-container
- %img.modal-profile-crop-image{ alt: "Avatar cropper" }
+ %img.modal-profile-crop-image{ alt: 'Avatar cropper' }
.crop-controls
.btn-group
- %button.btn.btn-primary{ data: { method: "zoom", option: "0.1" } }
+ %button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } }
%span.fa.fa-search-plus
- %button.btn.btn-primary{ data: { method: "zoom", option: "-0.1" } }
+ %button.btn.btn-primary{ data: { method: 'zoom', option: '-0.1' } }
%span.fa.fa-search-minus
.modal-footer
- %button.btn.btn-primary.js-upload-user-avatar{ :type => "button" }
+ %button.btn.btn-primary.js-upload-user-avatar{ type: 'button' }
Set new profile picture
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 3a1be3fa4b6..b778e8af121 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : ''
-- limited_container_width = fluid_layout || diff_view == :inline ? '' : 'limit-container-width'
+- limited_container_width = fluid_layout ? '' : 'limit-container-width'
- page_title "#{@commit.title} (#{@commit.short_id})", "Commits"
- page_description @commit.description
= render "projects/commits/head"
@@ -13,7 +13,8 @@
.block-connector
= render "projects/diffs/diffs", diffs: @diffs, environment: @environment
- = render "shared/notes/notes_with_form", :autocomplete => true
- - if can_collaborate_with_project?
- - %w(revert cherry-pick).each do |type|
- = render "projects/commit/change", type: type, commit: @commit, title: @commit.title
+ .limited-width-notes
+ = render "shared/notes/notes_with_form", :autocomplete => true
+ - if can_collaborate_with_project?
+ - %w(revert cherry-pick).each do |type|
+ = render "projects/commit/change", type: type, commit: @commit, title: @commit.title
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 9b2ec9ae41c..26f3c297260 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -3,24 +3,25 @@
.table-mobile-header{ role: 'rowheader' } ID
%strong.table-mobile-content ##{deployment.iid}
- .table-section.section-40{ role: 'gridcell' }
+ .table-section.section-30{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' } Commit
= render 'projects/deployments/commit', deployment: deployment
- .table-section.section-15.build-column{ role: 'gridcell' }
+ .table-section.section-25.build-column{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' } Job
- if deployment.deployable
- = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link table-mobile-content' do
- #{deployment.deployable.name} (##{deployment.deployable.id})
- - if deployment.user
- by
- = user_avatar(user: deployment.user, size: 20)
+ .table-mobile-content
+ = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do
+ #{deployment.deployable.name} (##{deployment.deployable.id})
+ - if deployment.user
+ by
+ = user_avatar(user: deployment.user, size: 20)
.table-section.section-15{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' } Created
%span.table-mobile-content= time_ago_with_tooltip(deployment.created_at)
.table-section.section-20.table-button-footer{ role: 'gridcell' }
- .btn-group.table-action-button
+ .btn-group.table-action-buttons
= render 'projects/deployments/actions', deployment: deployment
= render 'projects/deployments/rollback', deployment: deployment
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 296e37e20e6..78057facde7 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,10 +1,12 @@
+- @content_class = "limit-container-width" unless fluid_layout
+
= render "projects/settings/head"
.project-edit-container
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Project settings
- .col-lg-9
+ .col-lg-8
.project-edit-errors
= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f|
%fieldset
@@ -39,66 +41,66 @@
Sharing &amp; Permissions
.form_group.prepend-top-20.sharing-and-permissions
.row.js-visibility-select
- .col-md-9
+ .col-md-8
.label-light
= label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level
= link_to icon('question-circle'), help_page_path("public_access/public_access")
%span.help-block
- .col-md-3.visibility-select-container
+ .col-md-4.visibility-select-container
= render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
= f.fields_for :project_feature do |feature_fields|
%fieldset.features
.row
- .col-md-9.project-feature
+ .col-md-8.project-feature
= feature_fields.label :repository_access_level, "Repository", class: 'label-light'
%span.help-block View and edit files in this project
- .col-md-3.js-repo-access-level
+ .col-md-4.js-repo-access-level
= project_feature_access_select(:repository_access_level)
.row
- .col-md-9.project-feature.nested
+ .col-md-8.project-feature.nested
= feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
%span.help-block Submit changes to be merged upstream
- .col-md-3
+ .col-md-4
= project_feature_access_select(:merge_requests_access_level)
.row
- .col-md-9.project-feature.nested
+ .col-md-8.project-feature.nested
= feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
%span.help-block Build, test, and deploy your changes
- .col-md-3
+ .col-md-4
= project_feature_access_select(:builds_access_level)
.row
- .col-md-9.project-feature
+ .col-md-8.project-feature
= feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
%span.help-block Share code pastes with others out of Git repository
- .col-md-3
+ .col-md-4
= project_feature_access_select(:snippets_access_level)
.row
- .col-md-9.project-feature
+ .col-md-8.project-feature
= feature_fields.label :issues_access_level, "Issues", class: 'label-light'
%span.help-block Lightweight issue tracking system for this project
- .col-md-3
+ .col-md-4
= project_feature_access_select(:issues_access_level)
.row
- .col-md-9.project-feature
+ .col-md-8.project-feature
= feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
%span.help-block Pages for project documentation
- .col-md-3
+ .col-md-4
= project_feature_access_select(:wiki_access_level)
.form-group
= render 'shared/allow_request_access', form: f
- if Gitlab.config.lfs.enabled && current_user.admin?
.row.js-lfs-enabled
- .col-md-9
+ .col-md-8
= f.label :lfs_enabled, 'LFS', class: 'label-light'
%span.help-block
Git Large File Storage
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
- .col-md-3
+ .col-md-4
.select-wrapper
= f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select select-control', data: { field: 'lfs_enabled' }
= icon('chevron-down')
@@ -138,19 +140,19 @@
.row.prepend-top-default
%hr
.row.prepend-top-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0
Housekeeping
%p.append-bottom-0
%p
Runs a number of housekeeping tasks within the current repository,
such as compressing file revisions and removing unreachable objects.
- .col-lg-9
+ .col-lg-8
= link_to 'Housekeeping', housekeeping_namespace_project_path(@project.namespace, @project),
method: :post, class: "btn btn-default"
%hr
.row.prepend-top-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0
Export project
%p.append-bottom-0
@@ -159,7 +161,7 @@
%p
Once the exported file is ready, you will receive a notification email with a download link.
- .col-lg-9
+ .col-lg-8
- if @project.export_project_path
= link_to 'Download export', download_export_namespace_project_path(@project.namespace, @project),
@@ -190,7 +192,7 @@
- if can? current_user, :archive_project, @project
%hr
.row.prepend-top-default
- .col-lg-3
+ .col-lg-4
%h4.warning-title.prepend-top-0
- if @project.archived?
Unarchive project
@@ -201,7 +203,7 @@
Unarchiving the project will mark its repository as active. The project can be committed to.
- else
Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches.
- .col-lg-9
+ .col-lg-8
- if @project.archived?
%p
%strong Once active this project shows up in the search and on the dashboard.
@@ -216,10 +218,10 @@
method: :post, class: "btn btn-warning"
%hr
.row.prepend-top-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0.warning-title
Rename repository
- .col-lg-9
+ .col-lg-8
= render 'projects/errors'
= form_for([@project.namespace.becomes(Namespace), @project]) do |f|
.form-group.project_name_holder
@@ -244,12 +246,12 @@
- if can?(current_user, :change_namespace, @project)
%hr
.row.prepend-top-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0.danger-title
Transfer project to new group
%p.append-bottom-0
Please select the group you want to transfer this project to in the dropdown to the right.
- .col-lg-9
+ .col-lg-8
= form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
.form-group
= label_tag :new_namespace_id, nil, class: 'label-light' do
@@ -265,7 +267,7 @@
- if @project.forked? && can?(current_user, :remove_fork_project, @project)
%hr
.row.prepend-top-default.append-bottom-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0.danger-title
Remove fork relationship
%p.append-bottom-0
@@ -273,7 +275,7 @@
This will remove the fork relationship to source project
= succeed "." do
= link_to @project.forked_from_project.name_with_namespace, project_path(@project.forked_from_project)
- .col-lg-9
+ .col-lg-8
= form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_namespace_project_path(@project.namespace, @project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
%p
%strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.
@@ -281,12 +283,12 @@
- if can?(current_user, :remove_project, @project)
%hr
.row.prepend-top-default.append-bottom-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0.danger-title
Remove project
%p.append-bottom-0
Removing the project will delete its repository and all related resources including issues, merge requests etc.
- .col-lg-9
+ .col-lg-8
= form_tag(namespace_project_path(@project.namespace, @project), method: :delete) do
%p
%strong Removed projects cannot be restored!
diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml
index 676b7c345bc..776681ea09a 100644
--- a/app/views/projects/hooks/_index.html.haml
+++ b/app/views/projects/hooks/_index.html.haml
@@ -1,12 +1,12 @@
.row.prepend-top-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0
= page_title
%p
#{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
used for binding events when something is happening within the project.
- .col-lg-9.append-bottom-default
+ .col-lg-8.append-bottom-default
= form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit 'Add webhook', class: 'btn btn-create'
diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml
index 43bbd735059..3de518c8b9a 100644
--- a/app/views/projects/pipelines_settings/_badge.html.haml
+++ b/app/views/projects/pipelines_settings/_badge.html.haml
@@ -1,8 +1,8 @@
%div{ class: badge.title.gsub(' ', '-') }
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= badge.title.capitalize
- .col-lg-9
+ .col-lg-8
.prepend-top-10
.panel.panel-default
.panel-heading
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index 3b17daeb6da..580129ca809 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -1,8 +1,8 @@
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Pipelines
- .col-lg-9
+ .col-lg-8
= form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f|
%fieldset.builds-feature
- unless @repository.gitlab_ci_yml
diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml
index cfae371e169..fa99610c0be 100644
--- a/app/views/projects/project_members/_index.html.haml
+++ b/app/views/projects/project_members/_index.html.haml
@@ -1,5 +1,5 @@
.row.prepend-top-default
- .col-lg-3.settings-sidebar
+ .col-lg-4.settings-sidebar
%h4.prepend-top-0
Project members
- if can?(current_user, :admin_project_member, @project)
@@ -13,7 +13,7 @@
%i Masters
or
%i Owners
- .col-lg-9
+ .col-lg-8
.light
- if can?(current_user, :admin_project_member, @project)
%ul.nav-links.project-member-tabs{ role: 'tablist' }
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 9167789a69d..6dffc026392 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -23,3 +23,7 @@
- disabled_title = @service.disabled_title
= link_to 'Cancel', namespace_project_settings_integrations_path(@project.namespace, @project), class: 'btn btn-cancel'
+
+- if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true)
+ %hr
+ = render "projects/services/#{@service.to_param}/show"
diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml
index 86d5a0ec7b8..997b702da33 100644
--- a/app/views/projects/services/_index.html.haml
+++ b/app/views/projects/services/_index.html.haml
@@ -1,9 +1,9 @@
.row.prepend-top-default.append-bottom-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0
Project services
%p Project services allow you to integrate GitLab with other applications
- .col-lg-9
+ .col-lg-8
%table.table
%colgroup
%col
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
new file mode 100644
index 00000000000..c4ac384ca1a
--- /dev/null
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -0,0 +1,45 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('prometheus_metrics')
+
+.row.prepend-top-default.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring
+ .col-lg-3
+ %h4.prepend-top-0
+ Metrics
+ %p
+ Metrics are automatically configured and monitored
+ based on a library of metrics from popular exporters.
+ = link_to 'More information', '#'
+
+ .col-lg-9
+ .panel.panel-default.js-panel-monitored-metrics{ data: { "active-metrics" => "#{namespace_project_prometheus_active_metrics_path(@project.namespace, @project, :json)}" } }
+ .panel-heading
+ %h3.panel-title
+ Monitored
+ %span.badge.js-monitored-count 0
+ .panel-body
+ .loading-metrics.text-center.js-loading-metrics
+ = icon('spinner spin 3x', class: 'metrics-load-spinner')
+ %p Finding and configuring metrics...
+ .empty-metrics.text-center.hidden.js-empty-metrics
+ = custom_icon('icon_empty_metrics')
+ %p No metrics are being monitored. To start monitoring, deploy to an environment.
+ = link_to project_environments_path(@project), title: 'View environments', class: 'btn btn-success' do
+ View environments
+ %ul.list-unstyled.metrics-list.hidden.js-metrics-list
+
+ .panel.panel-default.hidden.js-panel-missing-env-vars
+ .panel-heading
+ %h3.panel-title
+ = icon('caret-right lg fw', class: 'panel-toggle js-panel-toggle', 'aria-label' => 'Toggle panel')
+ Missing environment variable
+ %span.badge.js-env-var-count 0
+ .panel-body.hidden
+ .flash-container
+ .flash-notice
+ .flash-text
+ To set up automatic monitoring, add the environment variable
+ %code
+ $CI_ENVIRONMENT_SLUG
+ to exporter&rsquo;s queries.
+ = link_to 'More information', '#'
+ %ul.list-unstyled.metrics-list.js-missing-var-metrics-list
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index e8d2e91bd76..00ccc3ec41e 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,3 +1,4 @@
+- @content_class = "limit-container-width" unless fluid_layout
- page_title "Pipelines"
= render "projects/settings/head"
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index f69992566b5..1d1d0849289 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -1,3 +1,4 @@
+- @content_class = "limit-container-width" unless fluid_layout
- page_title 'Integrations'
= render "projects/settings/head"
= render 'projects/hooks/index'
diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml
index 343807b87cd..1e7695ac397 100644
--- a/app/views/projects/settings/members/show.html.haml
+++ b/app/views/projects/settings/members/show.html.haml
@@ -1,3 +1,5 @@
+- @content_class = "limit-container-width" unless fluid_layout
+
- page_title "Members"
= render "projects/settings/head"
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 847f3c2f348..d8e448dd2af 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -1,3 +1,4 @@
+- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
= render 'shared/snippets/header'
@@ -9,4 +10,4 @@
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
- #notes= render "shared/notes/notes_with_form", :autocomplete => true
+ #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index de57cd4ba00..f9147815427 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,5 +1,5 @@
- if readme.rich_viewer
- %article.file-holder.readme-holder
+ %article.file-holder.readme-holder{ class: ("limited-width-container" unless fluid_layout) }
.js-file-title.file-title
= blob_icon readme.mode, readme.name
= link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path)) do
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index cc74e50a5e3..e9a2f803edd 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,7 +1,7 @@
.row.prepend-top-default.append-bottom-default.triggers-container
- .col-lg-3
+ .col-lg-4
= render "projects/triggers/content"
- .col-lg-9
+ .col-lg-8
.panel.panel-default
.panel-heading
%h4.panel-title
diff --git a/app/views/projects/variables/_index.html.haml b/app/views/projects/variables/_index.html.haml
index 1b852a9c5b3..5e6786f6698 100644
--- a/app/views/projects/variables/_index.html.haml
+++ b/app/views/projects/variables/_index.html.haml
@@ -1,7 +1,7 @@
.row.prepend-top-default.append-bottom-default
- .col-lg-3
+ .col-lg-4
= render "projects/variables/content"
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Add a variable
= render "projects/variables/form", btn_text: "Add new variable"
diff --git a/app/views/shared/icons/_icon_empty_metrics.svg b/app/views/shared/icons/_icon_empty_metrics.svg
new file mode 100644
index 00000000000..24fa353f3ba
--- /dev/null
+++ b/app/views/shared/icons/_icon_empty_metrics.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
+ <g fill="#E5E5E5">
+ <path d="M32 64C30.8954305 64 30 63.1045695 30 62 30 60.8954305 30.8954305 60 32 60 33.8894444 60 35.7536611 59.8131396 37.574335 59.4454933 38.6570511 59.2268618 39.7120017 59.9273408 39.9306331 61.0100569 40.1492646 62.0927729 39.4487856 63.1477235 38.3660695 63.366355 36.285133 63.7865558 34.1557023 64 32 64zM49.2301062 58.9696428C51.0302775 57.8173242 52.7114504 56.4871355 54.247711 55.0008916 55.0415758 54.232873 55.0625283 52.9667164 54.2945097 52.1728516 53.5264912 51.3789869 52.2603346 51.3580344 51.4664698 52.1260529 50.1212672 53.4274592 48.6493395 54.5920875 47.0736141 55.6007347 46.1433158 56.1962335 45.8719072 57.4331365 46.4674061 58.3634348 47.0629049 59.2937331 48.2998079 59.5651416 49.2301062 58.9696428zM61.0426034 45.4531856C61.9412068 43.5163476 62.6441937 41.4911051 63.1388045 39.4034279 63.393449 38.3286117 62.7285685 37.2508708 61.6537523 36.9962262 60.5789361 36.7415816 59.5011952 37.4064621 59.2465506 38.4812784 58.8141946 40.3061875 58.1997219 42.0764286 57.4141077 43.7697311 56.9492346 44.7717126 57.3846469 45.9608331 58.3866284 46.4257062 59.3886098 46.8905793 60.5777303 46.455167 61.0426034 45.4531856zM63.7270657 27.8034151C63.4476841 25.6718707 62.9558906 23.5863203 62.2616468 21.5714028 61.9018246 20.527084 60.7635435 19.9721898 59.7192246 20.3320119 58.6749058 20.6918341 58.1200116 21.8301152 58.4798337 22.874434 59.0867105 24.6357842 59.5166381 26.45898 59.760988 28.3232492 59.9045362 29.4184513 60.9087418 30.1899192 62.0039439 30.046371 63.099146 29.9028228 63.8706139 28.8986173 63.7270657 27.8034151zM56.4699838 11.3781121C55.0919588 9.74451505 53.5537382 8.25140603 51.8798083 6.92273835 51.0146495 6.23602588 49.7566092 6.38068523 49.0698968 7.24584403 48.3831843 8.11100284 48.5278436 9.36904308 49.3930024 10.0557555 50.8587525 11.2191822 52.2058153 12.5267396 53.4125204 13.9572433 54.1247279 14.8015385 55.3865225 14.9086168 56.2308177 14.1964094 57.0751129 13.484202 57.1821912 12.2224073 56.4699838 11.3781121zM41.481294 1.42849704C39.4470333.798260231 37.3474846.371987025 35.2067823.158824109 34.1076485.0493765922 33.1278998.851675811 33.0184523 1.95080957 32.9090048 3.04994333 33.711304 4.02969203 34.8104377 4.13913955 36.6833634 4.32563829 38.5191483 4.69835932 40.297557 5.24933028 41.3526509 5.57621023 42.4729622 4.98587613 42.7998421 3.93078217 43.1267221 2.8756882 42.536388 1.75537699 41.481294 1.42849704zM23.6558195 1.0993008C21.5852929 1.6571259 19.5822296 2.42161363 17.6728876 3.37914679 16.6855233 3.874309 16.2865147 5.07613416 16.7816769 6.06349841 17.2768392 7.05086266 18.4786643 7.44987125 19.4660286 6.95470905 21.1354949 6.11747332 22.8864813 5.44919307 24.6963667 4.96158787 25.7629079 4.67424869 26.3945759 3.57671185 26.1072367 2.51017072 25.8198975 1.44362959 24.7223606.811961615 23.6558195 1.0993008zM8.36290105 10.4291871C6.92120358 12.00815 5.63985273 13.7275139 4.53998784 15.5610549 3.97179016 16.5082746 4.27904822 17.7367631 5.22626792 18.3049608 6.17348763 18.8731585 7.40197615 18.5659004 7.97017383 17.6186807 8.9327668 16.0139803 10.054503 14.5087932 11.3168098 13.126301 12.0615972 12.3106016 12.0041117 11.0455771 11.1884123 10.3007897 10.372713 9.55600224 9.10768848 9.61348772 8.36290105 10.4291871zM.450120287 26.6230259C.151304663 28.3883054 0 30.1850053 0 32 0 32.2974081.00406268322 32.594367.0121750297 32.8908218.0423897377 33.994978.96197903 34.8655796 2.0661352 34.8353649 3.17029137 34.8051502 4.04089294 33.8855609 4.01067824 32.7814047 4.00356366 32.521412 4 32.2609289 4 32 4 30.4089462 4.13249902 28.8355581 4.39401589 27.2906242 4.57836807 26.2015475 3.84494393 25.1692294 2.75586724 24.9848772 1.66679054 24.800525.634472466 25.5339492.450120287 26.6230259zM2.45830096 44.3202494C3.28286321 46.2952494 4.30407075 48.1806071 5.50459135 49.9494734 6.124886 50.8634254 7.36863868 51.1014818 8.28259072 50.4811871 9.19654276 49.8608925 9.43459912 48.6171398 8.81430448 47.7031878 7.76386025 46.1554464 6.87058107 44.5062706 6.14951581 42.7791677 5.72395784 41.7598668 4.55266835 41.2785432 3.53336751 41.7041011 2.51406668 42.1296591 2.03274299 43.3009486 2.45830096 44.3202494zM13.73374 58.2776222C15.4883094 59.4994144 17.3614388 60.5433005 19.3262717 61.39161 20.3403619 61.8294398 21.5173756 61.3622885 21.9552054 60.3481983 22.3930351 59.3341082 21.9258838 58.1570945 20.9117937 57.7192647 19.1934726 56.9773858 17.5548741 56.0642026 16.0195384 54.9950736 15.1130877 54.3638678 13.8665707 54.5869979 13.2353649 55.4934487 12.6041591 56.3998995 12.8272892 57.6464164 13.73374 58.2776222zM30.6955071 63.9738646C29.5918263 63.9295649 28.7330282 62.9989428 28.7773279 61.895262 28.8216276 60.7915812 29.7522497 59.9327832 30.8559305 59.9770829 31.2344492 59.9922759 31.6140624 59.9999282 31.9946308 59.9999995 33.0992003 60.0002065 33.994463 60.8958047 33.994256 62.0003742 33.9940491 63.1049437 33.0984508 64.0002064 31.9938814 63.9999994 31.5600677 63.9999181 31.1272192 63.9911927 30.6955071 63.9738646zM30.1721098 44.2840559C30.7941711 46.023825 33.2407935 46.0619159 33.9167124 44.3423547L38.9452693 31.5495297 41.1315797 35.2685507C41.4908522 35.8796908 42.1468005 36.2549751 42.8557214 36.2549751L51.1106965 36.2549751C52.215266 36.2549751 53.1106965 35.3595446 53.1106965 34.2549751 53.1106965 33.1504056 52.215266 32.2549751 51.1106965 32.2549751L43.9999712 32.2549751 40.3112064 25.9802055C39.465988 24.5424477 37.3358287 24.7099356 36.7257006 26.2621229L32.1439734 37.9181973 26.2115967 21.3266406C25.5807315 19.562249 23.0875908 19.5563214 22.4483429 21.3176933L18.4775633 32.2587065 13 32.2587065C11.8954305 32.2587065 11 33.154137 11 34.2587065 11 35.363276 11.8954305 36.2587065 13 36.2587065L19.8793532 36.2587065C20.720826 36.2587065 21.4722973 35.732004 21.7593685 34.9410132L24.314328 27.9011249 30.1721098 44.2840559z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 752932e6045..9186c2ba9c9 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -3,7 +3,7 @@
.modal-content
.modal-header
%button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } }
- %span{ "aria-hidden": "true" } } ×
+ %span{ "aria-hidden": "true" } ×
%h4#custom-notifications-title.modal-title
#{ _('Custom notification events') }
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 813d8d69d8d..17b34c5eeb3 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -16,7 +16,7 @@
- else
= render "snippets/actions"
-.snippet-header
+.snippet-header.limited-header-width
%h2.snippet-title.prepend-top-0.append-bottom-0
= markdown_field(@snippet, :title)
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 216184eb839..8818590362d 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -1,3 +1,4 @@
+- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
= render 'shared/snippets/header'
@@ -9,4 +10,4 @@
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
- #notes= render "shared/notes/notes_with_form", :autocomplete => false
+ #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false
diff --git a/changelogs/unreleased/28717-support-additional-prometheus-metrics.yml b/changelogs/unreleased/28717-support-additional-prometheus-metrics.yml
new file mode 100644
index 00000000000..720a79b8e1c
--- /dev/null
+++ b/changelogs/unreleased/28717-support-additional-prometheus-metrics.yml
@@ -0,0 +1,4 @@
+---
+title: Additional Prometheus metrics support
+merge_request: 11712
+author:
diff --git a/changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml b/changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml
new file mode 100644
index 00000000000..590472c0990
--- /dev/null
+++ b/changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml
@@ -0,0 +1,4 @@
+---
+title: Update QA Dockerfile to lock Chrome browser version
+merge_request: 12071
+author:
diff --git a/changelogs/unreleased/34010-fix-linking-to-parallel-diff-line-number-creating-gray-box.yml b/changelogs/unreleased/34010-fix-linking-to-parallel-diff-line-number-creating-gray-box.yml
new file mode 100644
index 00000000000..705d44f8b10
--- /dev/null
+++ b/changelogs/unreleased/34010-fix-linking-to-parallel-diff-line-number-creating-gray-box.yml
@@ -0,0 +1,4 @@
+---
+title: Fix linking to line number on side-by-side diff creating empty discussion box
+merge_request:
+author:
diff --git a/changelogs/unreleased/commit-comments-limited-width.yml b/changelogs/unreleased/commit-comments-limited-width.yml
new file mode 100644
index 00000000000..97f50105495
--- /dev/null
+++ b/changelogs/unreleased/commit-comments-limited-width.yml
@@ -0,0 +1,4 @@
+---
+title: Limit commit & snippets comments width
+merge_request:
+author:
diff --git a/changelogs/unreleased/dm-unnecessary-top-padding.yml b/changelogs/unreleased/dm-unnecessary-top-padding.yml
new file mode 100644
index 00000000000..4557c06f8e7
--- /dev/null
+++ b/changelogs/unreleased/dm-unnecessary-top-padding.yml
@@ -0,0 +1,4 @@
+---
+title: Remove unnecessary top padding on group MR index
+merge_request:
+author:
diff --git a/changelogs/unreleased/doc-gitaly-network.yml b/changelogs/unreleased/doc-gitaly-network.yml
new file mode 100644
index 00000000000..5376d8d5096
--- /dev/null
+++ b/changelogs/unreleased/doc-gitaly-network.yml
@@ -0,0 +1,4 @@
+---
+title: Add option to run Gitaly on a remote server
+merge_request: 12381
+author:
diff --git a/changelogs/unreleased/mk-fix-breadcrumb-order-33938.yml b/changelogs/unreleased/mk-fix-breadcrumb-order-33938.yml
new file mode 100644
index 00000000000..790af692b92
--- /dev/null
+++ b/changelogs/unreleased/mk-fix-breadcrumb-order-33938.yml
@@ -0,0 +1,4 @@
+---
+title: Fix reversed breadcrumb order for nested groups
+merge_request: 12322
+author:
diff --git a/changelogs/unreleased/mk-fix-issue-34068.yml b/changelogs/unreleased/mk-fix-issue-34068.yml
new file mode 100644
index 00000000000..af0a9139568
--- /dev/null
+++ b/changelogs/unreleased/mk-fix-issue-34068.yml
@@ -0,0 +1,4 @@
+---
+title: Fix 500 when failing to create private group
+merge_request: 12394
+author:
diff --git a/changelogs/unreleased/project-readme-limited-width.yml b/changelogs/unreleased/project-readme-limited-width.yml
new file mode 100644
index 00000000000..17d87a5691e
--- /dev/null
+++ b/changelogs/unreleased/project-readme-limited-width.yml
@@ -0,0 +1,4 @@
+---
+title: Limit the width of the projects README text
+merge_request:
+author:
diff --git a/changelogs/unreleased/tc-refactor-projects-finder-init-collection.yml b/changelogs/unreleased/tc-refactor-projects-finder-init-collection.yml
new file mode 100644
index 00000000000..7bcbd6468c7
--- /dev/null
+++ b/changelogs/unreleased/tc-refactor-projects-finder-init-collection.yml
@@ -0,0 +1,4 @@
+---
+title: Add User#full_private_access? to check if user has access to all private groups & projects
+merge_request: 12373
+author:
diff --git a/config/boot.rb b/config/boot.rb
index 16de55d7a86..2d01092acd5 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -9,15 +9,3 @@ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
if ENV['RAILS_ENV'] == 'development' || ENV['RAILS_ENV'] == 'test'
ENV['prometheus_multiproc_dir'] ||= 'tmp/prometheus_multiproc_dir'
end
-
-# Default Bootsnap configuration from https://github.com/Shopify/bootsnap#usage
-require 'bootsnap'
-Bootsnap.setup(
- cache_dir: 'tmp/cache',
- development_mode: ENV['RAILS_ENV'] == 'development',
- load_path_cache: true,
- autoload_paths_cache: true,
- disable_trace: false,
- compile_cache_iseq: true,
- compile_cache_yaml: true
-)
diff --git a/config/initializers/active_record_data_types.rb b/config/initializers/active_record_data_types.rb
index beb97c6fce0..fef591c397d 100644
--- a/config/initializers/active_record_data_types.rb
+++ b/config/initializers/active_record_data_types.rb
@@ -4,21 +4,78 @@
if Gitlab::Database.postgresql?
require 'active_record/connection_adapters/postgresql_adapter'
- module ActiveRecord
- module ConnectionAdapters
- class PostgreSQLAdapter
- NATIVE_DATABASE_TYPES.merge!(datetime_with_timezone: { name: 'timestamptz' })
+ module ActiveRecord::ConnectionAdapters::PostgreSQL::OID
+ # Add the class `DateTimeWithTimeZone` so we can map `timestamptz` to it.
+ class DateTimeWithTimeZone < DateTime
+ def type
+ :datetime_with_timezone
end
end
end
+
+ module RegisterDateTimeWithTimeZone
+ # Run original `initialize_type_map` and then register `timestamptz` as a
+ # `DateTimeWithTimeZone`.
+ #
+ # Apparently it does not matter that the original `initialize_type_map`
+ # aliases `timestamptz` to `timestamp`.
+ #
+ # When schema dumping, `timestamptz` columns will be output as
+ # `t.datetime_with_timezone`.
+ def initialize_type_map(mapping)
+ super mapping
+
+ mapping.register_type 'timestamptz' do |_, _, sql_type|
+ precision = extract_precision(sql_type)
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::OID::DateTimeWithTimeZone.new(precision: precision)
+ end
+ end
+ end
+
+ class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
+ prepend RegisterDateTimeWithTimeZone
+
+ # Add column type `datetime_with_timezone` so we can do this in
+ # migrations:
+ #
+ # add_column(:users, :datetime_with_timezone)
+ #
+ NATIVE_DATABASE_TYPES[:datetime_with_timezone] = { name: 'timestamptz' }
+ end
elsif Gitlab::Database.mysql?
require 'active_record/connection_adapters/mysql2_adapter'
- module ActiveRecord
- module ConnectionAdapters
- class AbstractMysqlAdapter
- NATIVE_DATABASE_TYPES.merge!(datetime_with_timezone: { name: 'timestamp' })
+ module RegisterDateTimeWithTimeZone
+ # Run original `initialize_type_map` and then register `timestamp` as a
+ # `MysqlDateTimeWithTimeZone`.
+ #
+ # When schema dumping, `timestamp` columns will be output as
+ # `t.datetime_with_timezone`.
+ def initialize_type_map(mapping)
+ super mapping
+
+ mapping.register_type(%r(timestamp)i) do |sql_type|
+ precision = extract_precision(sql_type)
+ ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlDateTimeWithTimeZone.new(precision: precision)
end
end
end
+
+ class ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
+ prepend RegisterDateTimeWithTimeZone
+
+ # Add the class `DateTimeWithTimeZone` so we can map `timestamp` to it.
+ class MysqlDateTimeWithTimeZone < MysqlDateTime
+ def type
+ :datetime_with_timezone
+ end
+ end
+
+ # Add column type `datetime_with_timezone` so we can do this in
+ # migrations:
+ #
+ # add_column(:users, :datetime_with_timezone)
+ #
+ NATIVE_DATABASE_TYPES[:datetime_with_timezone] = { name: 'timestamp' }
+ end
end
diff --git a/config/initializers/active_record_table_definition.rb b/config/initializers/active_record_table_definition.rb
index 4f59e35f4da..8e3a1c7a62f 100644
--- a/config/initializers/active_record_table_definition.rb
+++ b/config/initializers/active_record_table_definition.rb
@@ -3,15 +3,15 @@
require 'active_record/connection_adapters/abstract/schema_definitions'
-# Appends columns `created_at` and `updated_at` to a table.
-#
-# It is used in table creation like:
-# create_table 'users' do |t|
-# t.timestamps_with_timezone
-# end
module ActiveRecord
module ConnectionAdapters
class TableDefinition
+ # Appends columns `created_at` and `updated_at` to a table.
+ #
+ # It is used in table creation like:
+ # create_table 'users' do |t|
+ # t.timestamps_with_timezone
+ # end
def timestamps_with_timezone(**options)
options[:null] = false if options[:null].nil?
@@ -19,6 +19,16 @@ module ActiveRecord
column(column_name, :datetime_with_timezone, options)
end
end
+
+ # Adds specified column with appropriate timestamp type
+ #
+ # It is used in table creation like:
+ # create_table 'users' do |t|
+ # t.datetime_with_timezone :did_something_at
+ # end
+ def datetime_with_timezone(column_name, **options)
+ column(column_name, :datetime_with_timezone, options)
+ end
end
end
end
diff --git a/config/initializers/bootstrap_form.rb b/config/initializers/bootstrap_form.rb
new file mode 100644
index 00000000000..11171b38a85
--- /dev/null
+++ b/config/initializers/bootstrap_form.rb
@@ -0,0 +1,7 @@
+module BootstrapFormBuilderCustomization
+ def label_class
+ "label-light"
+ end
+end
+
+BootstrapForm::FormBuilder.prepend(BootstrapFormBuilderCustomization)
diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb
new file mode 100644
index 00000000000..0fee832788d
--- /dev/null
+++ b/config/initializers/flipper.rb
@@ -0,0 +1,4 @@
+require 'flipper/middleware/memoizer'
+
+Rails.application.config.middleware.use Flipper::Middleware::Memoizer,
+ lambda { Feature.flipper }
diff --git a/config/prometheus/additional_metrics.yml b/config/prometheus/additional_metrics.yml
new file mode 100644
index 00000000000..daecde49570
--- /dev/null
+++ b/config/prometheus/additional_metrics.yml
@@ -0,0 +1,32 @@
+- group: Kubernetes
+ priority: 1
+ metrics:
+ - title: "Memory usage"
+ y_label: "Values"
+ required_metrics:
+ - container_memory_usage_bytes
+ weight: 1
+ queries:
+ - query_range: 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20'
+ label: Container memory
+ unit: MiB
+ - title: "Current memory usage"
+ required_metrics:
+ - container_memory_usage_bytes
+ weight: 1
+ queries:
+ - query: 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20'
+ display_empty: false
+ unit: MiB
+ - title: "CPU usage"
+ required_metrics:
+ - container_cpu_usage_seconds_total
+ weight: 1
+ queries:
+ - query_range: 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100'
+ - title: "Current CPU usage"
+ required_metrics:
+ - container_cpu_usage_seconds_total
+ weight: 1
+ queries:
+ - query: 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100'
diff --git a/config/routes/project.rb b/config/routes/project.rb
index f95cc3101d3..19e18c733b1 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -73,6 +73,10 @@ constraints(ProjectUrlConstrainer.new) do
resource :mattermost, only: [:new, :create]
+ namespace :prometheus do
+ get :active_metrics
+ end
+
resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do
member do
put :enable
@@ -153,6 +157,7 @@ constraints(ProjectUrlConstrainer.new) do
post :stop
get :terminal
get :metrics
+ get :additional_metrics
get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil }
end
@@ -163,6 +168,7 @@ constraints(ProjectUrlConstrainer.new) do
resources :deployments, only: [:index] do
member do
get :metrics
+ get :additional_metrics
end
end
end
diff --git a/config/webpack.config.js b/config/webpack.config.js
index fb91ffef7e7..2e8c94655c1 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -55,6 +55,7 @@ var config = {
pipelines: './pipelines/pipelines_bundle.js',
pipelines_details: './pipelines/pipeline_details_bundle.js',
profile: './profile/profile_bundle.js',
+ prometheus_metrics: './prometheus_metrics',
protected_branches: './protected_branches/protected_branches_bundle.js',
protected_tags: './protected_tags',
sidebar: './sidebar/sidebar_bundle.js',
@@ -240,6 +241,7 @@ if (IS_DEV_SERVER) {
port: DEV_SERVER_PORT,
headers: { 'Access-Control-Allow-Origin': '*' },
stats: 'errors-only',
+ hot: DEV_SERVER_LIVERELOAD,
inline: DEV_SERVER_LIVERELOAD
};
config.output.publicPath = '//' + DEV_SERVER_HOST + ':' + DEV_SERVER_PORT + config.output.publicPath;
@@ -247,6 +249,9 @@ if (IS_DEV_SERVER) {
// watch node_modules for changes if we encounter a missing module compile error
new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules'))
);
+ if (DEV_SERVER_LIVERELOAD) {
+ config.plugins.push(new webpack.HotModuleReplacementPlugin());
+ }
}
if (WEBPACK_REPORT) {
diff --git a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb
index 4d6a61bd614..5336b036bca 100644
--- a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb
+++ b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb
@@ -2,6 +2,8 @@
class SetMissingStageOnCiBuilds < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:ci_builds, :stage, :test) do |table, query|
query.where(table[:stage].eq(nil))
diff --git a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb
index b2a2ce41391..abe8e701e23 100644
--- a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb
+++ b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb
@@ -5,6 +5,8 @@ class DropAndReaddHasExternalWikiInProjects < ActiveRecord::Migration
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:projects, :has_external_wiki, nil) do |table, query|
query.where(table[:has_external_wiki].not_eq(nil))
diff --git a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb
index febd2c0e65e..f8486e3e1a6 100644
--- a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb
+++ b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb
@@ -4,6 +4,8 @@ class SetConfidentialIssuesEventsOnWebhooks < ActiveRecord::Migration
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:web_hooks, :confidential_issues_events, true) do |table, query|
query.where(table[:issues_events].eq(true))
diff --git a/db/migrate/20160919144305_add_type_to_labels.rb b/db/migrate/20160919144305_add_type_to_labels.rb
index 2d2725ccf59..d08b339cd27 100644
--- a/db/migrate/20160919144305_add_type_to_labels.rb
+++ b/db/migrate/20160919144305_add_type_to_labels.rb
@@ -5,6 +5,8 @@ class AddTypeToLabels < ActiveRecord::Migration
DOWNTIME = true
DOWNTIME_REASON = 'Labels will not work as expected until this migration is complete.'
+ disable_ddl_transaction!
+
def change
add_column :labels, :type, :string
diff --git a/db/migrate/20161018124658_make_project_owners_masters.rb b/db/migrate/20161018124658_make_project_owners_masters.rb
index fe11699c196..cb93b449067 100644
--- a/db/migrate/20161018124658_make_project_owners_masters.rb
+++ b/db/migrate/20161018124658_make_project_owners_masters.rb
@@ -4,6 +4,8 @@ class MakeProjectOwnersMasters < ActiveRecord::Migration
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:members, :access_level, 40) do |table, query|
query.where(table[:access_level].eq(50).and(table[:source_type].eq('Project')))
diff --git a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb
index c7cada6dfc5..6b15e5caccf 100644
--- a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb
+++ b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb
@@ -4,6 +4,8 @@ class RenameSlackAndMattermostNotificationServices < ActiveRecord::Migration
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:services, :type, 'SlackService') do |table, query|
query.where(table[:type].eq('SlackNotificationService'))
diff --git a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb
index b1c9eed1148..01fff680183 100644
--- a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb
+++ b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb
@@ -4,6 +4,8 @@ class ResetRelativePositionForIssue < ActiveRecord::Migration
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:issues, :relative_position, nil) do |table, query|
query.where(table[:relative_position].not_eq(nil))
@@ -11,5 +13,6 @@ class ResetRelativePositionForIssue < ActiveRecord::Migration
end
def down
+ # noop
end
end
diff --git a/db/post_migrate/20170317162059_update_upload_paths_to_system.rb b/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
index 9a77b0bbdfb..ca2912f8dce 100644
--- a/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
+++ b/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
@@ -7,6 +7,8 @@ class UpdateUploadPathsToSystem < ActiveRecord::Migration
DOWNTIME = false
AFFECTED_MODELS = %w(User Project Note Namespace Appearance)
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:uploads, :path, replace_sql(arel_table[:path], base_directory, new_upload_dir)) do |_table, query|
query.where(uploads_to_switch_to_new_path)
diff --git a/db/post_migrate/20170406142253_migrate_user_project_view.rb b/db/post_migrate/20170406142253_migrate_user_project_view.rb
index 22f0f2ac200..c4e910b3b44 100644
--- a/db/post_migrate/20170406142253_migrate_user_project_view.rb
+++ b/db/post_migrate/20170406142253_migrate_user_project_view.rb
@@ -7,6 +7,8 @@ class MigrateUserProjectView < ActiveRecord::Migration
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:users, :project_view, 2) do |table, query|
query.where(table[:project_view].eq(0))
diff --git a/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb
index 0a4a2d3867a..f77078ddd70 100644
--- a/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb
+++ b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb
@@ -3,6 +3,8 @@ class AddHeadPipelineForEachMergeRequest < ActiveRecord::Migration
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
disable_statement_timeout
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index 48929910a9c..332457cb384 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -33,6 +33,145 @@ prometheus_listen_addr = "localhost:9236"
Changes to `/home/git/gitaly/config.toml` are applied when you run `service
gitlab restart`.
+## Running Gitaly on its own server
+
+> This is an optional way to deploy Gitaly which can benefit GitLab
+installations that are larger than a single machine. Most
+installations will be better served with the default configuration
+used by Omnibus and the GitLab source installation guide.
+
+Starting with GitLab 9.4 it is possible to run Gitaly on a different
+server from the rest of the application. This can improve performance
+when running GitLab with its repositories stored on an NFS server.
+
+At the moment (GitLab 9.4) Gitaly is not yet a replacement for NFS
+because some parts of GitLab still bypass Gitaly when accessing Git
+repositories. If you choose to deploy Gitaly on your NFS server you
+must still also mount your Git shares on your GitLab application
+servers.
+
+Gitaly network traffic is unencrypted so you should use a firewall to
+restrict access to your Gitaly server.
+
+Below we describe how to configure a Gitaly server at address
+`gitaly.internal:9999` with secret token `abc123secret`. We assume
+your GitLab installation has two repository storages, `default` and
+`storage1`.
+
+### Client side token configuration
+
+Start by configuring a token on the client side.
+
+Omnibus installations:
+
+```ruby
+# /etc/gitlab/gitlab.rb
+gitlab_rails['gitaly_token'] = 'abc123secret'
+```
+
+Source installations:
+
+```yaml
+# /home/git/gitlab/config/gitlab.yml
+gitlab:
+ gitaly:
+ token: 'abc123secret'
+```
+
+You need to reconfigure (Omnibus) or restart (source) for these
+changes to be picked up.
+
+### Gitaly server configuration
+
+Next, on the Gitaly server, we need to configure storage paths, enable
+the network listener and configure the token.
+
+Note: if you want to reduce the risk of downtime when you enable
+authentication you can temporarily disable enforcement, see [the
+documentation on configuring Gitaly
+authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication)
+.
+
+In most or all cases the storage paths below end in `/repositories`. Check the
+directory layout on your Gitaly server to be sure.
+
+Omnibus installations:
+
+```ruby
+# /etc/gitlab/gitlab.rb
+gitaly['listen_addr'] = '0.0.0.0:9999'
+gitaly['auth_token'] = 'abc123secret'
+gitaly['storage'] = [
+ { 'name' => 'default', 'path' => '/path/to/default/repositories' },
+ { 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' },
+]
+```
+
+Source installations:
+
+```toml
+# /home/git/gitaly/config.toml
+listen_addr = '0.0.0.0:9999'
+
+[auth]
+token = 'abc123secret'
+
+[[storage]
+name = 'default'
+path = '/path/to/default/repositories'
+
+[[storage]]
+name = 'storage1'
+path = '/path/to/storage1/repositories'
+```
+
+Again, reconfigure (Omnibus) or restart (source).
+
+### Converting clients to use the Gitaly server
+
+Now as the final step update the client machines to switch from using
+their local Gitaly service to the new Gitaly server you just
+configured. This is a risky step because if there is any sort of
+network, firewall, or name resolution problem preventing your GitLab
+server from reaching the Gitaly server then all Gitaly requests will
+fail.
+
+We assume that your Gitaly server can be reached at
+`gitaly.internal:9999` from your GitLab server, and that your GitLab
+NFS shares are mounted at `/mnt/gitlab/default` and
+`/mnt/gitlab/storage1` respectively.
+
+Omnibus installations:
+
+```ruby
+# /etc/gitlab/gitlab.rb
+git_data_dirs({
+ { 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitlab.internal:9999' } },
+ { 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitlab.internal:9999' } },
+})
+```
+
+Source installations:
+
+```yaml
+# /home/git/gitlab/config/gitlab.yml
+gitlab:
+ repositories:
+ storages:
+ default:
+ path: /mnt/gitlab/default/repositories
+ gitaly_address: tcp://gitlab.internal:9999
+ storage1:
+ path: /mnt/gitlab/storage1/repositories
+ gitaly_address: tcp://gitlab.internal:9999
+```
+
+Now reconfigure (Omnibus) or restart (source). When you tail the
+Gitaly logs on your Gitaly server (`sudo gitlab-ctl tail gitaly` or
+`tail -f /home/git/gitlab/log/gitaly.log`) you should see requests
+coming in. One sure way to trigger a Gitaly request is to clone a
+repository from your GitLab server over HTTP.
+
## Configuring GitLab to not use Gitaly
Gitaly is still an optional component in GitLab 9.3. This means you
diff --git a/lib/feature.rb b/lib/feature.rb
index 5650a1c1334..d3d972564af 100644
--- a/lib/feature.rb
+++ b/lib/feature.rb
@@ -39,8 +39,6 @@ class Feature
get(key).disable
end
- private
-
def flipper
@flipper ||= begin
adapter = Flipper::Adapters::ActiveRecord.new(
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 60cce9c6d9e..0643c56db9b 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -222,6 +222,12 @@ module Gitlab
#
# rubocop: disable Metrics/AbcSize
def update_column_in_batches(table, column, value)
+ if transaction_open?
+ raise 'update_column_in_batches can not be run inside a transaction, ' \
+ 'you can disable transactions by calling disable_ddl_transaction! ' \
+ 'in the body of your migration class'
+ end
+
table = Arel::Table.new(table)
count_arel = table.project(Arel.star.count.as('count'))
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index f825568f194..cf7829a583b 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -318,7 +318,7 @@ module Gitlab
end
def init_from_gitaly(diff)
- @diff = diff.patch if diff.respond_to?(:patch)
+ @diff = encode!(diff.patch) if diff.respond_to?(:patch)
@new_path = encode!(diff.to_path.dup)
@old_path = encode!(diff.from_path.dup)
@a_mode = diff.old_mode.to_s(8)
diff --git a/lib/gitlab/gitaly_client/diff_stitcher.rb b/lib/gitlab/gitaly_client/diff_stitcher.rb
index d84e8d752dc..65d81dc5d46 100644
--- a/lib/gitlab/gitaly_client/diff_stitcher.rb
+++ b/lib/gitlab/gitaly_client/diff_stitcher.rb
@@ -13,7 +13,10 @@ module Gitlab
@rpc_response.each do |diff_msg|
if current_diff.nil?
diff_params = diff_msg.to_h.slice(*GitalyClient::Diff::FIELDS)
- diff_params[:patch] = diff_msg.raw_patch_data
+ # gRPC uses frozen strings by default, and we need to have an unfrozen string as it
+ # gets processed further down the line. So we unfreeze the first chunk of the patch
+ # in case it's the only chunk we receive for this diff.
+ diff_params[:patch] = diff_msg.raw_patch_data.dup
current_diff = GitalyClient::Diff.new(diff_params)
else
diff --git a/lib/gitlab/prometheus/additional_metrics_parser.rb b/lib/gitlab/prometheus/additional_metrics_parser.rb
new file mode 100644
index 00000000000..cb95daf2260
--- /dev/null
+++ b/lib/gitlab/prometheus/additional_metrics_parser.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Prometheus
+ module AdditionalMetricsParser
+ extend self
+
+ def load_groups_from_yaml
+ additional_metrics_raw.map(&method(:group_from_entry))
+ end
+
+ private
+
+ def validate!(obj)
+ raise ParsingError.new(obj.errors.full_messages.join('\n')) unless obj.valid?
+ end
+
+ def group_from_entry(entry)
+ entry[:name] = entry.delete(:group)
+ entry[:metrics]&.map! do |entry|
+ Metric.new(entry).tap(&method(:validate!))
+ end
+
+ MetricGroup.new(entry).tap(&method(:validate!))
+ end
+
+ def additional_metrics_raw
+ load_yaml_file&.map(&:deep_symbolize_keys).freeze
+ end
+
+ def load_yaml_file
+ @loaded_yaml_file ||= YAML.load_file(Rails.root.join('config/prometheus/additional_metrics.yml'))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/metric.rb b/lib/gitlab/prometheus/metric.rb
new file mode 100644
index 00000000000..f54b2c6aaff
--- /dev/null
+++ b/lib/gitlab/prometheus/metric.rb
@@ -0,0 +1,16 @@
+module Gitlab
+ module Prometheus
+ class Metric
+ include ActiveModel::Model
+
+ attr_accessor :title, :required_metrics, :weight, :y_label, :queries
+
+ validates :title, :required_metrics, :weight, :y_label, :queries, presence: true
+
+ def initialize(params = {})
+ super(params)
+ @y_label ||= 'Values'
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/metric_group.rb b/lib/gitlab/prometheus/metric_group.rb
new file mode 100644
index 00000000000..729fef34b35
--- /dev/null
+++ b/lib/gitlab/prometheus/metric_group.rb
@@ -0,0 +1,14 @@
+module Gitlab
+ module Prometheus
+ class MetricGroup
+ include ActiveModel::Model
+
+ attr_accessor :name, :priority, :metrics
+ validates :name, :priority, :metrics, presence: true
+
+ def self.all
+ AdditionalMetricsParser.load_groups_from_yaml
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/parsing_error.rb b/lib/gitlab/prometheus/parsing_error.rb
new file mode 100644
index 00000000000..49cc0e16080
--- /dev/null
+++ b/lib/gitlab/prometheus/parsing_error.rb
@@ -0,0 +1,5 @@
+module Gitlab
+ module Prometheus
+ ParsingError = Class.new(StandardError)
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
new file mode 100644
index 00000000000..67c69d9ccf3
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class AdditionalMetricsDeploymentQuery < BaseQuery
+ include QueryAdditionalMetrics
+
+ def query(deployment_id)
+ Deployment.find_by(id: deployment_id).try do |deployment|
+ query_context = {
+ environment_slug: deployment.environment.slug,
+ environment_filter: %{container_name!="POD",environment="#{deployment.environment.slug}"},
+ timeframe_start: (deployment.created_at - 30.minutes).to_f,
+ timeframe_end: (deployment.created_at + 30.minutes).to_f
+ }
+
+ query_metrics(query_context)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
new file mode 100644
index 00000000000..b5a679ddd79
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class AdditionalMetricsEnvironmentQuery < BaseQuery
+ include QueryAdditionalMetrics
+
+ def query(environment_id)
+ Environment.find_by(id: environment_id).try do |environment|
+ query_context = {
+ environment_slug: environment.slug,
+ environment_filter: %{container_name!="POD",environment="#{environment.slug}"},
+ timeframe_start: 8.hours.ago.to_f,
+ timeframe_end: Time.now.to_f
+ }
+
+ query_metrics(query_context)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/base_query.rb b/lib/gitlab/prometheus/queries/base_query.rb
index 2a2eb4ae57f..c60828165bd 100644
--- a/lib/gitlab/prometheus/queries/base_query.rb
+++ b/lib/gitlab/prometheus/queries/base_query.rb
@@ -3,7 +3,7 @@ module Gitlab
module Queries
class BaseQuery
attr_accessor :client
- delegate :query_range, :query, to: :client, prefix: true
+ delegate :query_range, :query, :label_values, :series, to: :client, prefix: true
def raw_memory_usage_query(environment_slug)
%{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20}
diff --git a/lib/gitlab/prometheus/queries/deployment_query.rb b/lib/gitlab/prometheus/queries/deployment_query.rb
index 2cc08731f8d..170f483540e 100644
--- a/lib/gitlab/prometheus/queries/deployment_query.rb
+++ b/lib/gitlab/prometheus/queries/deployment_query.rb
@@ -1,26 +1,31 @@
-module Gitlab::Prometheus::Queries
- class DeploymentQuery < BaseQuery
- def query(deployment_id)
- deployment = Deployment.find_by(id: deployment_id)
- environment_slug = deployment.environment.slug
+module Gitlab
+ module Prometheus
+ module Queries
+ class DeploymentQuery < BaseQuery
+ def query(deployment_id)
+ Deployment.find_by(id: deployment_id).try do |deployment|
+ environment_slug = deployment.environment.slug
- memory_query = raw_memory_usage_query(environment_slug)
- memory_avg_query = %{avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}[30m]))}
- cpu_query = raw_cpu_usage_query(environment_slug)
- cpu_avg_query = %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[30m])) * 100}
+ memory_query = raw_memory_usage_query(environment_slug)
+ memory_avg_query = %{avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}[30m]))}
+ cpu_query = raw_cpu_usage_query(environment_slug)
+ cpu_avg_query = %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[30m])) * 100}
- timeframe_start = (deployment.created_at - 30.minutes).to_f
- timeframe_end = (deployment.created_at + 30.minutes).to_f
+ timeframe_start = (deployment.created_at - 30.minutes).to_f
+ timeframe_end = (deployment.created_at + 30.minutes).to_f
- {
- memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end),
- memory_before: client_query(memory_avg_query, time: deployment.created_at.to_f),
- memory_after: client_query(memory_avg_query, time: timeframe_end),
+ {
+ memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end),
+ memory_before: client_query(memory_avg_query, time: deployment.created_at.to_f),
+ memory_after: client_query(memory_avg_query, time: timeframe_end),
- cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
- cpu_before: client_query(cpu_avg_query, time: deployment.created_at.to_f),
- cpu_after: client_query(cpu_avg_query, time: timeframe_end)
- }
+ cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
+ cpu_before: client_query(cpu_avg_query, time: deployment.created_at.to_f),
+ cpu_after: client_query(cpu_avg_query, time: timeframe_end)
+ }
+ end
+ end
+ end
end
end
end
diff --git a/lib/gitlab/prometheus/queries/environment_query.rb b/lib/gitlab/prometheus/queries/environment_query.rb
index 01d756d7284..66f29d95177 100644
--- a/lib/gitlab/prometheus/queries/environment_query.rb
+++ b/lib/gitlab/prometheus/queries/environment_query.rb
@@ -1,20 +1,25 @@
-module Gitlab::Prometheus::Queries
- class EnvironmentQuery < BaseQuery
- def query(environment_id)
- environment = Environment.find_by(id: environment_id)
- environment_slug = environment.slug
- timeframe_start = 8.hours.ago.to_f
- timeframe_end = Time.now.to_f
+module Gitlab
+ module Prometheus
+ module Queries
+ class EnvironmentQuery < BaseQuery
+ def query(environment_id)
+ Environment.find_by(id: environment_id).try do |environment|
+ environment_slug = environment.slug
+ timeframe_start = 8.hours.ago.to_f
+ timeframe_end = Time.now.to_f
- memory_query = raw_memory_usage_query(environment_slug)
- cpu_query = raw_cpu_usage_query(environment_slug)
+ memory_query = raw_memory_usage_query(environment_slug)
+ cpu_query = raw_cpu_usage_query(environment_slug)
- {
- memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end),
- memory_current: client_query(memory_query, time: timeframe_end),
- cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
- cpu_current: client_query(cpu_query, time: timeframe_end)
- }
+ {
+ memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end),
+ memory_current: client_query(memory_query, time: timeframe_end),
+ cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
+ cpu_current: client_query(cpu_query, time: timeframe_end)
+ }
+ end
+ end
+ end
end
end
end
diff --git a/lib/gitlab/prometheus/queries/matched_metrics_query.rb b/lib/gitlab/prometheus/queries/matched_metrics_query.rb
new file mode 100644
index 00000000000..d4894c87f8d
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/matched_metrics_query.rb
@@ -0,0 +1,80 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class MatchedMetricsQuery < BaseQuery
+ MAX_QUERY_ITEMS = 40.freeze
+
+ def query
+ groups_data.map do |group, data|
+ {
+ group: group.name,
+ priority: group.priority,
+ active_metrics: data[:active_metrics],
+ metrics_missing_requirements: data[:metrics_missing_requirements]
+ }
+ end
+ end
+
+ private
+
+ def groups_data
+ metrics_groups = groups_with_active_metrics(Gitlab::Prometheus::MetricGroup.all)
+ lookup = active_series_lookup(metrics_groups)
+
+ groups = {}
+
+ metrics_groups.each do |group|
+ groups[group] ||= { active_metrics: 0, metrics_missing_requirements: 0 }
+ active_metrics = group.metrics.count { |metric| metric.required_metrics.all?(&lookup.method(:has_key?)) }
+
+ groups[group][:active_metrics] += active_metrics
+ groups[group][:metrics_missing_requirements] += group.metrics.count - active_metrics
+ end
+
+ groups
+ end
+
+ def active_series_lookup(metric_groups)
+ timeframe_start = 8.hours.ago
+ timeframe_end = Time.now
+
+ series = metric_groups.flat_map(&:metrics).flat_map(&:required_metrics).uniq
+
+ lookup = series.each_slice(MAX_QUERY_ITEMS).flat_map do |batched_series|
+ client_series(*batched_series, start: timeframe_start, stop: timeframe_end)
+ .select(&method(:has_matching_label))
+ .map { |series_info| [series_info['__name__'], true] }
+ end
+ lookup.to_h
+ end
+
+ def has_matching_label(series_info)
+ series_info.key?('environment')
+ end
+
+ def available_metrics
+ @available_metrics ||= client_label_values || []
+ end
+
+ def filter_active_metrics(metric_group)
+ metric_group.metrics.select! do |metric|
+ metric.required_metrics.all?(&available_metrics.method(:include?))
+ end
+ metric_group
+ end
+
+ def groups_with_active_metrics(metric_groups)
+ metric_groups.map(&method(:filter_active_metrics)).select { |group| group.metrics.any? }
+ end
+
+ def metrics_with_required_series(metric_groups)
+ metric_groups.flat_map do |group|
+ group.metrics.select do |metric|
+ metric.required_metrics.all?(&available_metrics.method(:include?))
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/query_additional_metrics.rb b/lib/gitlab/prometheus/queries/query_additional_metrics.rb
new file mode 100644
index 00000000000..e44be770544
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/query_additional_metrics.rb
@@ -0,0 +1,73 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ module QueryAdditionalMetrics
+ def query_metrics(query_context)
+ query_processor = method(:process_query).curry[query_context]
+
+ groups = matched_metrics.map do |group|
+ metrics = group.metrics.map do |metric|
+ {
+ title: metric.title,
+ weight: metric.weight,
+ y_label: metric.y_label,
+ queries: metric.queries.map(&query_processor).select(&method(:query_with_result))
+ }
+ end
+
+ {
+ group: group.name,
+ priority: group.priority,
+ metrics: metrics.select(&method(:metric_with_any_queries))
+ }
+ end
+
+ groups.select(&method(:group_with_any_metrics))
+ end
+
+ private
+
+ def metric_with_any_queries(metric)
+ metric[:queries]&.count&.> 0
+ end
+
+ def group_with_any_metrics(group)
+ group[:metrics]&.count&.> 0
+ end
+
+ def query_with_result(query)
+ query[:result]&.any? do |item|
+ item&.[](:values)&.any? || item&.[](:value)&.any?
+ end
+ end
+
+ def process_query(context, query)
+ query_with_result = query.dup
+ result =
+ if query.key?(:query_range)
+ client_query_range(query[:query_range] % context, start: context[:timeframe_start], stop: context[:timeframe_end])
+ else
+ client_query(query[:query] % context, time: context[:timeframe_end])
+ end
+ query_with_result[:result] = result&.map(&:deep_symbolize_keys)
+ query_with_result
+ end
+
+ def available_metrics
+ @available_metrics ||= client_label_values || []
+ end
+
+ def matched_metrics
+ result = Gitlab::Prometheus::MetricGroup.all.map do |group|
+ group.metrics.select! do |metric|
+ metric.required_metrics.all?(&available_metrics.method(:include?))
+ end
+ group
+ end
+
+ result.select { |group| group.metrics.any? }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus_client.rb b/lib/gitlab/prometheus_client.rb
index 5b51a1779dd..aa94614bf18 100644
--- a/lib/gitlab/prometheus_client.rb
+++ b/lib/gitlab/prometheus_client.rb
@@ -29,6 +29,14 @@ module Gitlab
end
end
+ def label_values(name = '__name__')
+ json_api_get("label/#{name}/values")
+ end
+
+ def series(*matches, start: 8.hours.ago, stop: Time.now)
+ json_api_get('series', 'match': matches, start: start.to_f, end: stop.to_f)
+ end
+
private
def json_api_get(type, args = {})
diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb
index 36e5b5041a6..48f3d950779 100644
--- a/lib/gitlab/visibility_level.rb
+++ b/lib/gitlab/visibility_level.rb
@@ -28,7 +28,7 @@ module Gitlab
def levels_for_user(user = nil)
return [PUBLIC] unless user
- if user.admin?
+ if user.full_private_access?
[PRIVATE, INTERNAL, PUBLIC]
elsif user.external?
[PUBLIC]
diff --git a/qa/Dockerfile b/qa/Dockerfile
index 9e2a74ef991..97ae1961e34 100644
--- a/qa/Dockerfile
+++ b/qa/Dockerfile
@@ -1,6 +1,9 @@
FROM ruby:2.3
LABEL maintainer "Grzegorz Bizon <grzegorz@gitlab.com>"
+ENV CHROME_VERSION 59.0.3071.109-1
+ENV CHROME_DRIVER_VERSION 2.30
+
##
# Update APT sources and install some dependencies
#
@@ -8,15 +11,16 @@ RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list
RUN apt-get update && apt-get install -y wget git unzip xvfb
##
-# At this point Google Chrome Beta is 59 - first version with headless support
+# Install Google Chrome version with headless support
#
-RUN wget -q https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
-RUN dpkg -i google-chrome-beta_current_amd64.deb; apt-get -fy install
+RUN curl -sS -L https://dl.google.com/linux/linux_signing_key.pub | apt-key add -
+RUN echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
+RUN apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y google-chrome-stable=$CHROME_VERSION
##
# Install chromedriver to make it work with Selenium
#
-RUN wget -q https://chromedriver.storage.googleapis.com/2.29/chromedriver_linux64.zip
+RUN wget -q https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip
RUN unzip chromedriver_linux64.zip -d /usr/local/bin
RUN apt-get clean
diff --git a/qa/qa/specs/config.rb b/qa/qa/specs/config.rb
index 78a93828d36..b341aa3094a 100644
--- a/qa/qa/specs/config.rb
+++ b/qa/qa/specs/config.rb
@@ -55,7 +55,7 @@ module QA
Capybara.register_driver :chrome do |app|
capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
'chromeOptions' => {
- 'binary' => '/opt/google/chrome-beta/google-chrome-beta',
+ 'binary' => '/usr/bin/google-chrome-stable',
'args' => %w[headless no-sandbox disable-gpu]
}
)
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 3f99e2ff596..a2720c9b81e 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -99,6 +99,36 @@ describe ApplicationController do
end
end
+ describe 'response format' do
+ controller(described_class) do
+ def index
+ respond_to do |format|
+ format.json do
+ head :ok
+ end
+ end
+ end
+ end
+
+ context 'when format is handled' do
+ let(:requested_format) { :json }
+
+ it 'returns 200 response' do
+ get :index, private_token: user.private_token, format: requested_format
+
+ expect(response).to have_http_status 200
+ end
+ end
+
+ context 'when format is not handled' do
+ it 'returns 404 response' do
+ get :index, private_token: user.private_token
+
+ expect(response).to have_http_status 404
+ end
+ end
+ end
+
describe '#authenticate_user_from_rss_token' do
describe "authenticating a user from an RSS token" do
controller(described_class) do
diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb
index 4c69443314d..0dbfcf97f6f 100644
--- a/spec/controllers/projects/deployments_controller_spec.rb
+++ b/spec/controllers/projects/deployments_controller_spec.rb
@@ -42,6 +42,7 @@ describe Projects::DeploymentsController do
before do
allow(controller).to receive(:deployment).and_return(deployment)
end
+
context 'when metrics are disabled' do
before do
allow(deployment).to receive(:has_metrics?).and_return false
@@ -108,6 +109,69 @@ describe Projects::DeploymentsController do
end
end
+ describe 'GET #additional_metrics' do
+ let(:deployment) { create(:deployment, project: project, environment: environment) }
+
+ before do
+ allow(controller).to receive(:deployment).and_return(deployment)
+ end
+
+ context 'when metrics are disabled' do
+ before do
+ allow(deployment).to receive(:has_metrics?).and_return false
+ end
+
+ it 'responds with not found' do
+ get :metrics, deployment_params(id: deployment.id)
+
+ expect(response).to be_not_found
+ end
+ end
+
+ context 'when metrics are enabled' do
+ let(:prometheus_service) { double('prometheus_service') }
+
+ before do
+ allow(deployment.project).to receive(:prometheus_service).and_return(prometheus_service)
+ end
+
+ context 'when environment has no metrics' do
+ before do
+ expect(deployment).to receive(:additional_metrics).and_return({})
+ end
+
+ it 'returns a empty response 204 response' do
+ get :additional_metrics, deployment_params(id: deployment.id, format: :json)
+ expect(response).to have_http_status(204)
+ expect(response.body).to eq('')
+ end
+ end
+
+ context 'when environment has some metrics' do
+ let(:empty_metrics) do
+ {
+ success: true,
+ metrics: {},
+ last_update: 42
+ }
+ end
+
+ before do
+ expect(deployment).to receive(:additional_metrics).and_return(empty_metrics)
+ end
+
+ it 'returns a metrics JSON document' do
+ get :additional_metrics, deployment_params(id: deployment.id, format: :json)
+
+ expect(response).to be_ok
+ expect(json_response['success']).to be(true)
+ expect(json_response['metrics']).to eq({})
+ expect(json_response['last_update']).to eq(42)
+ end
+ end
+ end
+ end
+
def deployment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 9ec3c53174e..ad0b046742d 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -318,6 +318,48 @@ describe Projects::EnvironmentsController do
end
end
+ describe 'GET #additional_metrics' do
+ before do
+ allow(controller).to receive(:environment).and_return(environment)
+ end
+
+ context 'when environment has no metrics' do
+ before do
+ expect(environment).to receive(:additional_metrics).and_return(nil)
+ end
+
+ context 'when requesting metrics as JSON' do
+ it 'returns a metrics JSON document' do
+ get :additional_metrics, environment_params(format: :json)
+
+ expect(response).to have_http_status(204)
+ expect(json_response).to eq({})
+ end
+ end
+ end
+
+ context 'when environment has some metrics' do
+ before do
+ expect(environment)
+ .to receive(:additional_metrics)
+ .and_return({
+ success: true,
+ data: {},
+ last_update: 42
+ })
+ end
+
+ it 'returns a metrics JSON document' do
+ get :additional_metrics, environment_params(format: :json)
+
+ expect(response).to be_ok
+ expect(json_response['success']).to be(true)
+ expect(json_response['data']).to eq({})
+ expect(json_response['last_update']).to eq(42)
+ end
+ end
+ end
+
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/prometheus_controller_spec.rb b/spec/controllers/projects/prometheus_controller_spec.rb
new file mode 100644
index 00000000000..eddf7275975
--- /dev/null
+++ b/spec/controllers/projects/prometheus_controller_spec.rb
@@ -0,0 +1,59 @@
+require('spec_helper')
+
+describe Projects::PrometheusController do
+ let(:user) { create(:user) }
+ let!(:project) { create(:empty_project) }
+
+ let(:prometheus_service) { double('prometheus_service') }
+
+ before do
+ allow(controller).to receive(:project).and_return(project)
+ allow(project).to receive(:prometheus_service).and_return(prometheus_service)
+
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ describe 'GET #active_metrics' do
+ context 'when prometheus metrics are enabled' do
+ context 'when data is not present' do
+ before do
+ allow(prometheus_service).to receive(:matched_metrics).and_return({})
+ end
+
+ it 'returns no content response' do
+ get :active_metrics, project_params(format: :json)
+
+ expect(response).to have_http_status(204)
+ end
+ end
+
+ context 'when data is available' do
+ let(:sample_response) { { some_data: 1 } }
+
+ before do
+ allow(prometheus_service).to receive(:matched_metrics).and_return(sample_response)
+ end
+
+ it 'returns no content response' do
+ get :active_metrics, project_params(format: :json)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to eq(sample_response.deep_stringify_keys)
+ end
+ end
+
+ context 'when requesting non json response' do
+ it 'returns not found response' do
+ get :active_metrics, project_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+
+ def project_params(opts = {})
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project)
+ end
+end
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index e7366a7fd1c..30bc25cf88a 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -25,6 +25,14 @@ FactoryGirl.define do
})
end
+ factory :prometheus_service do
+ project factory: :empty_project
+ active true
+ properties({
+ api_url: 'https://prometheus.example.com/'
+ })
+ end
+
factory :jira_service do
project factory: :empty_project
active true
diff --git a/spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json b/spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
new file mode 100644
index 00000000000..47b5d283b8c
--- /dev/null
+++ b/spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
@@ -0,0 +1,58 @@
+{
+ "items": {
+ "properties": {
+ "group": {
+ "type": "string"
+ },
+ "metrics": {
+ "items": {
+ "properties": {
+ "queries": {
+ "items": {
+ "properties": {
+ "query_range": {
+ "type": "string"
+ },
+ "query": {
+ "type": "string"
+ },
+ "result": {
+ "type": "any"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "title": {
+ "type": "string"
+ },
+ "weight": {
+ "type": "integer"
+ },
+ "y_label": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "required": [
+ "metrics",
+ "title",
+ "weight"
+ ],
+ "type": "array"
+ },
+ "priority": {
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "required": [
+ "group",
+ "priority",
+ "metrics"
+ ],
+ "type": "array"
+} \ No newline at end of file
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index a7c06e577a2..84a60ce13fc 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe GroupsHelper do
+ include ApplicationHelper
+
describe 'group_icon' do
avatar_file_path = File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')
@@ -81,4 +83,15 @@ describe GroupsHelper do
end
end
end
+
+ describe 'group_title' do
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+ let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+
+ it 'outputs the groups in the correct order' do
+ expect(group_title(very_deep_nested_group)).to match(/>#{group.name}<\/a>.*>#{nested_group.name}<\/a>.*>#{deep_nested_group.name}<\/a>/)
+ end
+ end
end
diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js
index 832877de71c..c0a7323a505 100644
--- a/spec/javascripts/boards/board_new_issue_spec.js
+++ b/spec/javascripts/boards/board_new_issue_spec.js
@@ -12,6 +12,7 @@ import './mock_data';
describe('Issue boards new issue form', () => {
let vm;
let list;
+ let newIssueMock;
const promiseReturn = {
json() {
return {
@@ -21,7 +22,11 @@ describe('Issue boards new issue form', () => {
};
const submitIssue = () => {
- vm.$el.querySelector('.btn-success').click();
+ const dummySubmitEvent = {
+ preventDefault() {},
+ };
+ vm.$refs.submitButton = vm.$el.querySelector('.btn-success');
+ return vm.submit(dummySubmitEvent);
};
beforeEach((done) => {
@@ -32,29 +37,35 @@ describe('Issue boards new issue form', () => {
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
- setTimeout(() => {
- list = new List(listObj);
-
- spyOn(gl.boardService, 'newIssue').and.callFake(() => new Promise((resolve, reject) => {
- if (vm.title === 'error') {
- reject();
- } else {
- resolve(promiseReturn);
- }
- }));
-
- vm = new BoardNewIssueComp({
- propsData: {
- list,
- },
- }).$mount();
-
- done();
- }, 0);
+ list = new List(listObj);
+
+ newIssueMock = Promise.resolve(promiseReturn);
+ spyOn(list, 'newIssue').and.callFake(() => newIssueMock);
+
+ vm = new BoardNewIssueComp({
+ propsData: {
+ list,
+ },
+ }).$mount();
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
});
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
+ it('calls submit if submit button is clicked', (done) => {
+ spyOn(vm, 'submit');
+ vm.title = 'Testing Title';
+
+ Vue.nextTick()
+ .then(() => {
+ vm.$el.querySelector('.btn-success').click();
+
+ expect(vm.submit.calls.count()).toBe(1);
+ expect(vm.$refs['submit-button']).toBe(vm.$el.querySelector('.btn-success'));
+ })
+ .then(done)
+ .catch(done.fail);
});
it('disables submit button if title is empty', () => {
@@ -64,136 +75,122 @@ describe('Issue boards new issue form', () => {
it('enables submit button if title is not empty', (done) => {
vm.title = 'Testing Title';
- setTimeout(() => {
- expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
- expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
-
- done();
- }, 0);
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
+ expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('clears title after clicking cancel', (done) => {
vm.$el.querySelector('.btn-default').click();
- setTimeout(() => {
- expect(vm.title).toBe('');
- done();
- }, 0);
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.title).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
});
it('does not create new issue if title is empty', (done) => {
- submitIssue();
-
- setTimeout(() => {
- expect(gl.boardService.newIssue).not.toHaveBeenCalled();
- done();
- }, 0);
+ submitIssue()
+ .then(() => {
+ expect(list.newIssue).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
});
describe('submit success', () => {
it('creates new issue', (done) => {
vm.title = 'submit title';
- setTimeout(() => {
- submitIssue();
-
- expect(gl.boardService.newIssue).toHaveBeenCalled();
- done();
- }, 0);
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(list.newIssue).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
});
it('enables button after submit', (done) => {
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
- done();
- }, 0);
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('clears title after submit', (done) => {
vm.title = 'submit issue';
- Vue.nextTick(() => {
- submitIssue();
-
- setTimeout(() => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(vm.title).toBe('');
- done();
- }, 0);
- });
- });
-
- it('adds new issue to top of list after submit request', (done) => {
- vm.title = 'submit issue';
-
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
- expect(list.issues.length).toBe(2);
- expect(list.issues[0].title).toBe('submit issue');
- expect(list.issues[0].subscribed).toBe(true);
- done();
- }, 0);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('sets detail issue after submit', (done) => {
expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe(undefined);
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue');
- done();
- }, 0);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('sets detail list after submit', (done) => {
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id);
- done();
- }, 0);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
describe('submit error', () => {
- it('removes issue', (done) => {
+ beforeEach(() => {
+ newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!'));
vm.title = 'error';
+ });
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
+ it('removes issue', (done) => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(list.issues.length).toBe(1);
- done();
- }, 0);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('shows error', (done) => {
- vm.title = 'error';
-
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(vm.error).toBe(true);
- done();
- }, 0);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js
index 8e3d9fd77a0..db50829a276 100644
--- a/spec/javascripts/boards/list_spec.js
+++ b/spec/javascripts/boards/list_spec.js
@@ -150,4 +150,41 @@ describe('List model', () => {
expect(list.getIssues).toHaveBeenCalled();
});
});
+
+ describe('newIssue', () => {
+ beforeEach(() => {
+ spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
+ json() {
+ return {
+ iid: 42,
+ };
+ },
+ }));
+ });
+
+ it('adds new issue to top of list', (done) => {
+ list.issues.push(new ListIssue({
+ title: 'Testing',
+ iid: _.random(10000),
+ confidential: false,
+ labels: [list.label],
+ assignees: [],
+ }));
+ const dummyIssue = new ListIssue({
+ title: 'new issue',
+ iid: _.random(10000),
+ confidential: false,
+ labels: [list.label],
+ assignees: [],
+ });
+
+ list.newIssue(dummyIssue)
+ .then(() => {
+ expect(list.issues.length).toBe(2);
+ expect(list.issues[0]).toBe(dummyIssue);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
index 8d239c9cc3f..16ae649ee60 100644
--- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
@@ -48,18 +48,23 @@ describe('Filtered Search Manager', () => {
</div>
`);
+ spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
+ });
+
+ const initializeManager = () => {
+ /* eslint-disable jasmine/no-unsafe-spy */
spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
- spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
+ /* eslint-enable jasmine/no-unsafe-spy */
input = document.querySelector('.filtered-search');
tokensContainer = document.querySelector('.tokens-container');
manager = new gl.FilteredSearchManager();
manager.setup();
- });
+ };
afterEach(() => {
manager.cleanup();
@@ -67,33 +72,34 @@ describe('Filtered Search Manager', () => {
describe('class constructor', () => {
const isLocalStorageAvailable = 'isLocalStorageAvailable';
- let filteredSearchManager;
beforeEach(() => {
spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable);
spyOn(recentSearchesStoreSrc, 'default');
spyOn(RecentSearchesRoot.prototype, 'render');
-
- filteredSearchManager = new gl.FilteredSearchManager();
- filteredSearchManager.setup();
-
- return filteredSearchManager;
});
it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
+ manager = new gl.FilteredSearchManager();
+
expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({
isLocalStorageAvailable,
allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
});
});
+ });
+
+ describe('setup', () => {
+ beforeEach(() => {
+ manager = new gl.FilteredSearchManager();
+ });
it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError()));
spyOn(window, 'Flash');
- filteredSearchManager = new gl.FilteredSearchManager();
- filteredSearchManager.setup();
+ manager.setup();
expect(window.Flash).not.toHaveBeenCalled();
});
@@ -102,6 +108,7 @@ describe('Filtered Search Manager', () => {
describe('searchState', () => {
beforeEach(() => {
spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {});
+ initializeManager();
});
it('should blur button', () => {
@@ -148,6 +155,10 @@ describe('Filtered Search Manager', () => {
describe('search', () => {
const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('should search with a single word', (done) => {
input.value = 'searchTerm';
@@ -197,6 +208,10 @@ describe('Filtered Search Manager', () => {
});
describe('handleInputPlaceholder', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('should render placeholder when there is no input', () => {
expect(input.placeholder).toEqual(placeholder);
});
@@ -223,6 +238,10 @@ describe('Filtered Search Manager', () => {
});
describe('checkForBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
@@ -260,6 +279,10 @@ describe('Filtered Search Manager', () => {
});
describe('removeToken', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('removes token even when it is already selected', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
@@ -291,6 +314,7 @@ describe('Filtered Search Manager', () => {
describe('removeSelectedTokenKeydown', () => {
beforeEach(() => {
+ initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
);
@@ -344,27 +368,39 @@ describe('Filtered Search Manager', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough();
spyOn(gl.FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough();
spyOn(gl.FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough();
- manager.removeSelectedToken();
+ initializeManager();
});
it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
+ manager.removeSelectedToken();
+
expect(gl.FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
});
it('calls handleInputPlaceholder', () => {
+ manager.removeSelectedToken();
+
expect(manager.handleInputPlaceholder).toHaveBeenCalled();
});
it('calls toggleClearSearchButton', () => {
+ manager.removeSelectedToken();
+
expect(manager.toggleClearSearchButton).toHaveBeenCalled();
});
it('calls update dropdown offset', () => {
+ manager.removeSelectedToken();
+
expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled();
});
});
describe('toggleInputContainerFocus', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('toggles on focus', () => {
input.focus();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true);
diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb
index 0715f4d5f6b..daaddd8f390 100644
--- a/spec/javascripts/fixtures/merge_requests.rb
+++ b/spec/javascripts/fixtures/merge_requests.rb
@@ -55,20 +55,27 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request)
end
- it 'merge_requests/changes_tab_with_comments.json' do |example|
+ it 'merge_requests/inline_changes_tab_with_comments.json' do |example|
create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
create(:note_on_merge_request, author: admin, project: project, noteable: merge_request)
render_merge_request(example.description, merge_request, action: :diffs, format: :json)
end
+ it 'merge_requests/parallel_changes_tab_with_comments.json' do |example|
+ create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
+ create(:note_on_merge_request, author: admin, project: project, noteable: merge_request)
+ render_merge_request(example.description, merge_request, action: :diffs, format: :json, view: 'parallel')
+ end
+
private
- def render_merge_request(fixture_file_name, merge_request, action: :show, format: :html)
+ def render_merge_request(fixture_file_name, merge_request, action: :show, format: :html, view: 'inline')
get action,
namespace_id: project.namespace.to_param,
project_id: project,
id: merge_request.to_param,
- format: format
+ format: format,
+ view: view
expect(response).to be_success
store_frontend_fixture(response, fixture_file_name)
diff --git a/spec/javascripts/fixtures/prometheus_service.rb b/spec/javascripts/fixtures/prometheus_service.rb
new file mode 100644
index 00000000000..3200577b326
--- /dev/null
+++ b/spec/javascripts/fixtures/prometheus_service.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') }
+ let!(:service) { create(:prometheus_service, project: project) }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('services/prometheus')
+ end
+
+ before(:each) do
+ sign_in(admin)
+ end
+
+ it 'services/prometheus/prometheus_service.html.raw' do |example|
+ get :edit,
+ namespace_id: namespace,
+ project_id: project,
+ id: service.to_param
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js
index 2a77f7259da..aaffb56fa94 100644
--- a/spec/javascripts/groups/groups_spec.js
+++ b/spec/javascripts/groups/groups_spec.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import eventHub from '~/groups/event_hub';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import groupItemComponent from '~/groups/components/group_item.vue';
import groupsComponent from '~/groups/components/groups.vue';
@@ -46,6 +47,12 @@ describe('Groups Component', () => {
expect(component.$el.querySelector('#group-1120')).toBeDefined();
});
+ it('should respect the order of groups', () => {
+ const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
+ expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
+ expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
+ });
+
it('should render group and its subgroup', () => {
const lists = component.$el.querySelectorAll('.group-list-tree');
@@ -54,11 +61,26 @@ describe('Groups Component', () => {
expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true);
expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true);
- expect(lists[2].querySelector('#group-1120').textContent).toContain(groups[1119].subGroups[1120].name);
+ expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name);
});
it('should remove prefix of parent group', () => {
expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
});
+
+ it('should remove the group after leaving the group', (done) => {
+ spyOn(window, 'confirm').and.returnValue(true);
+
+ eventHub.$on('leaveGroup', (group, collection) => {
+ store.removeGroup(group, collection);
+ });
+
+ component.$el.querySelector('#group-12 .leave-group').click();
+
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('#group-12')).toBeNull();
+ done();
+ });
+ });
});
});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 276e01fc82f..9df92318864 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -3,17 +3,9 @@ import '~/render_math';
import '~/render_gfm';
import issuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
+import Poll from '~/lib/utils/poll';
import issueShowData from '../mock_data';
-const issueShowInterceptor = data => (request, next) => {
- next(request.respondWith(JSON.stringify(data), {
- status: 200,
- headers: {
- 'POLL-INTERVAL': 1,
- },
- }));
-};
-
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
}
@@ -24,10 +16,10 @@ describe('Issuable output', () => {
let vm;
beforeEach(() => {
- const IssuableDescriptionComponent = Vue.extend(issuableApp);
- Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
-
spyOn(eventHub, '$emit');
+ spyOn(Poll.prototype, 'makeRequest');
+
+ const IssuableDescriptionComponent = Vue.extend(issuableApp);
vm = new IssuableDescriptionComponent({
propsData: {
@@ -54,9 +46,18 @@ describe('Issuable output', () => {
});
it('should render a title/description/edited and update title/description/edited on update', (done) => {
- setTimeout(() => {
- const editedText = vm.$el.querySelector('.edited-text');
+ vm.poll.options.successCallback({
+ json() {
+ return issueShowData.initialRequest;
+ },
+ });
+ let editedText;
+ Vue.nextTick()
+ .then(() => {
+ editedText = vm.$el.querySelector('.edited-text');
+ })
+ .then(() => {
expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
@@ -64,22 +65,27 @@ describe('Issuable output', () => {
expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
-
- Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
-
- setTimeout(() => {
- expect(document.querySelector('title').innerText).toContain('2 (#1)');
- expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
- expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
- expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
- expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
- expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
- expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
- expect(editedText.querySelector('time')).toBeTruthy();
-
- done();
+ })
+ .then(() => {
+ vm.poll.options.successCallback({
+ json() {
+ return issueShowData.secondRequest;
+ },
});
- });
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('2 (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
+ expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
+ expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ })
+ .then(done)
+ .catch(done.fail);
});
it('shows actions if permissions are correct', (done) => {
@@ -344,21 +350,23 @@ describe('Issuable output', () => {
describe('open form', () => {
it('shows locked warning if form is open & data is different', (done) => {
- Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
+ vm.poll.options.successCallback({
+ json() {
+ return issueShowData.initialRequest;
+ },
+ });
Vue.nextTick()
- .then(() => new Promise((resolve) => {
- setTimeout(resolve);
- }))
.then(() => {
vm.openForm();
- Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
-
- return new Promise((resolve) => {
- setTimeout(resolve);
+ vm.poll.options.successCallback({
+ json() {
+ return issueShowData.secondRequest;
+ },
});
})
+ .then(Vue.nextTick)
.then(() => {
expect(
vm.formState.lockedWarningVisible,
@@ -367,9 +375,8 @@ describe('Issuable output', () => {
expect(
vm.$el.querySelector('.alert'),
).not.toBeNull();
-
- done();
})
+ .then(done)
.catch(done.fail);
});
});
diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js
index b6d0ce02c4f..9e9eb17d439 100644
--- a/spec/javascripts/merge_request_notes_spec.js
+++ b/spec/javascripts/merge_request_notes_spec.js
@@ -15,7 +15,7 @@ describe('Merge request notes', () => {
gl.utils = gl.utils || {};
const discussionTabFixture = 'merge_requests/diff_comment.html.raw';
- const changesTabJsonFixture = 'merge_requests/changes_tab_with_comments.json';
+ const changesTabJsonFixture = 'merge_requests/inline_changes_tab_with_comments.json';
preloadFixtures(discussionTabFixture, changesTabJsonFixture);
describe('Discussion tab with diff comments', () => {
diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js
index 9916d2c1e21..bb6b5d852d3 100644
--- a/spec/javascripts/merge_request_tabs_spec.js
+++ b/spec/javascripts/merge_request_tabs_spec.js
@@ -22,7 +22,15 @@ import 'vendor/jquery.scrollTo';
};
$.extend(stubLocation, defaults, stubs || {});
};
- preloadFixtures('merge_requests/merge_request_with_task_list.html.raw', 'merge_requests/diff_comment.html.raw');
+
+ const inlineChangesTabJsonFixture = 'merge_requests/inline_changes_tab_with_comments.json';
+ const parallelChangesTabJsonFixture = 'merge_requests/parallel_changes_tab_with_comments.json';
+ preloadFixtures(
+ 'merge_requests/merge_request_with_task_list.html.raw',
+ 'merge_requests/diff_comment.html.raw',
+ inlineChangesTabJsonFixture,
+ parallelChangesTabJsonFixture
+ );
beforeEach(function () {
this.class = new gl.MergeRequestTabs({ stubLocation: stubLocation });
@@ -271,6 +279,19 @@ import 'vendor/jquery.scrollTo';
});
describe('loadDiff', function () {
+ beforeEach(() => {
+ loadFixtures('merge_requests/diff_comment.html.raw');
+ spyOn(window.gl.utils, 'getPagePath').and.returnValue('merge_requests');
+ window.gl.ImageFile = () => {};
+ window.notes = new Notes('', []);
+ spyOn(window.notes, 'toggleDiffNote').and.callThrough();
+ });
+
+ afterEach(() => {
+ delete window.gl.ImageFile;
+ delete window.notes;
+ });
+
it('requires an absolute pathname', function () {
spyOn($, 'ajax').and.callFake(function (options) {
expect(options.url).toEqual('/foo/bar/merge_requests/1/diffs.json');
@@ -279,43 +300,112 @@ import 'vendor/jquery.scrollTo';
this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
});
- describe('with note fragment hash', () => {
+ describe('with inline diff', () => {
+ let noteId;
+ let noteLineNumId;
+
beforeEach(() => {
- loadFixtures('merge_requests/diff_comment.html.raw');
- spyOn(window.gl.utils, 'getPagePath').and.returnValue('merge_requests');
- window.notes = new Notes('', []);
- spyOn(window.notes, 'toggleDiffNote').and.callThrough();
- });
+ const diffsResponse = getJSONFixture(inlineChangesTabJsonFixture);
+
+ const $html = $(diffsResponse.html);
+ noteId = $html.find('.note').attr('id');
+ noteLineNumId = $html
+ .find('.note')
+ .closest('.notes_holder')
+ .prev('.line_holder')
+ .find('a[data-linenumber]')
+ .attr('href')
+ .replace('#', '');
- afterEach(() => {
- delete window.notes;
+ spyOn($, 'ajax').and.callFake(function (options) {
+ options.success(diffsResponse);
+ });
});
- it('should expand and scroll to linked fragment hash #note_xxx', function () {
- const noteId = 'note_1';
- spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId);
- spyOn($, 'ajax').and.callFake(function (options) {
- options.success({ html: `<div id="${noteId}">foo</div>` });
+ describe('with note fragment hash', () => {
+ it('should expand and scroll to linked fragment hash #note_xxx', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId);
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({
+ target: jasmine.any(Object),
+ lineType: 'old',
+ forceShow: true,
+ });
});
- this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+ it('should gracefully ignore non-existant fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
- expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({
- target: jasmine.any(Object),
- lineType: 'old',
- forceShow: true,
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
});
});
- it('should gracefully ignore non-existant fragment hash', function () {
- spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
+ describe('with line number fragment hash', () => {
+ it('should gracefully ignore line number fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteLineNumId);
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteLineNumId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('with parallel diff', () => {
+ let noteId;
+ let noteLineNumId;
+
+ beforeEach(() => {
+ const diffsResponse = getJSONFixture(parallelChangesTabJsonFixture);
+
+ const $html = $(diffsResponse.html);
+ noteId = $html.find('.note').attr('id');
+ noteLineNumId = $html
+ .find('.note')
+ .closest('.notes_holder')
+ .prev('.line_holder')
+ .find('a[data-linenumber]')
+ .attr('href')
+ .replace('#', '');
+
spyOn($, 'ajax').and.callFake(function (options) {
- options.success({ html: '' });
+ options.success(diffsResponse);
});
+ });
+
+ describe('with note fragment hash', () => {
+ it('should expand and scroll to linked fragment hash #note_xxx', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId);
- this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
- expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ expect(noteId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({
+ target: jasmine.any(Object),
+ lineType: 'new',
+ forceShow: true,
+ });
+ });
+
+ it('should gracefully ignore non-existant fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with line number fragment hash', () => {
+ it('should gracefully ignore line number fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteLineNumId);
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteLineNumId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
});
});
});
diff --git a/spec/javascripts/prometheus_metrics/mock_data.js b/spec/javascripts/prometheus_metrics/mock_data.js
new file mode 100644
index 00000000000..3af56df92e2
--- /dev/null
+++ b/spec/javascripts/prometheus_metrics/mock_data.js
@@ -0,0 +1,41 @@
+export const metrics = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 0,
+ },
+];
+
+export const missingVarMetrics = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 1,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 3,
+ },
+];
diff --git a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
new file mode 100644
index 00000000000..2b3a821dbd9
--- /dev/null
+++ b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
@@ -0,0 +1,158 @@
+import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+import PANEL_STATE from '~/prometheus_metrics/constants';
+import { metrics, missingVarMetrics } from './mock_data';
+
+describe('PrometheusMetrics', () => {
+ const FIXTURE = 'services/prometheus/prometheus_service.html.raw';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('constructor', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should initialize wrapper element refs on class object', () => {
+ expect(prometheusMetrics.$wrapper).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsCount).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsLoading).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsList).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined();
+ expect(prometheusMetrics.$panelToggle).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined();
+ });
+
+ it('should initialize metadata on class object', () => {
+ expect(prometheusMetrics.backOffRequestCounter).toEqual(0);
+ expect(prometheusMetrics.activeMetricsEndpoint).toContain('/test');
+ });
+ });
+
+ describe('showMonitoringMetricsPanelState', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show loading state when called with `loading`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+
+ it('should show metrics list when called with `list`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+ });
+
+ it('should show empty state when called with `empty`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+ });
+
+ describe('populateActiveMetrics', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show monitored metrics list', () => {
+ prometheusMetrics.populateActiveMetrics(metrics);
+
+ const $metricsListLi = prometheusMetrics.$monitoredMetricsList.find('li');
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('12');
+ expect($metricsListLi.length).toEqual(metrics.length);
+ expect($metricsListLi.first().find('.badge').text()).toEqual(`${metrics[0].active_metrics}`);
+ });
+
+ it('should show missing environment variables list', () => {
+ prometheusMetrics.populateActiveMetrics(missingVarMetrics);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$missingEnvVarPanel.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$missingEnvVarMetricCount.text()).toEqual('2');
+ expect(prometheusMetrics.$missingEnvVarPanel.find('li').length).toEqual(2);
+ expect(prometheusMetrics.$missingEnvVarPanel.find('.flash-container')).toBeDefined();
+ });
+ });
+
+ describe('loadActiveMetrics', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show loader animation while response is being loaded and hide it when request is complete', (done) => {
+ const deferred = $.Deferred();
+ spyOn($, 'getJSON').and.returnValue(deferred.promise());
+
+ prometheusMetrics.loadActiveMetrics();
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect($.getJSON).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
+
+ deferred.resolve({ data: metrics, success: true });
+
+ setTimeout(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should show empty state if response failed to load', (done) => {
+ const deferred = $.Deferred();
+ spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn(prometheusMetrics, 'populateActiveMetrics');
+
+ prometheusMetrics.loadActiveMetrics();
+
+ deferred.reject();
+
+ setTimeout(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ done();
+ });
+ });
+
+ it('should populate metrics list once response is loaded', (done) => {
+ const deferred = $.Deferred();
+ spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn(prometheusMetrics, 'populateActiveMetrics');
+
+ prometheusMetrics.loadActiveMetrics();
+
+ deferred.resolve({ data: metrics, success: true });
+
+ setTimeout(() => {
+ expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index f0d51bd0902..075b72f35b2 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -22,6 +22,19 @@ window.gl = window.gl || {};
window.gl.TEST_HOST = 'http://test.host';
window.gon = window.gon || {};
+let hasUnhandledPromiseRejections = false;
+
+window.addEventListener('unhandledrejection', (event) => {
+ hasUnhandledPromiseRejections = true;
+ console.error('Unhandled promise rejection:');
+ console.error(event.reason.stack || event.reason);
+});
+
+const checkUnhandledPromiseRejections = (done) => {
+ expect(hasUnhandledPromiseRejections).toBe(false);
+ done();
+};
+
// HACK: Chrome 59 disconnects if there are too many synchronous tests in a row
// because it appears to lock up the thread that communicates to Karma's socket
// This async beforeEach gets called on every spec and releases the JS thread long
@@ -63,6 +76,10 @@ testsContext.keys().forEach(function (path) {
}
});
+it('has no unhandled Promise rejections', (done) => {
+ setTimeout(checkUnhandledPromiseRejections(done), 1000);
+});
+
// if we're generating coverage reports, make sure to include all files so
// that we can catch files with 0% coverage
// see: https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 6a0485112c1..4259be3f522 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -262,39 +262,53 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
end
describe '#update_column_in_batches' do
- before do
- create_list(:empty_project, 5)
- end
+ context 'when running outside of a transaction' do
+ before do
+ expect(model).to receive(:transaction_open?).and_return(false)
- it 'updates all the rows in a table' do
- model.update_column_in_batches(:projects, :import_error, 'foo')
+ create_list(:empty_project, 5)
+ end
- expect(Project.where(import_error: 'foo').count).to eq(5)
- end
+ it 'updates all the rows in a table' do
+ model.update_column_in_batches(:projects, :import_error, 'foo')
- it 'updates boolean values correctly' do
- model.update_column_in_batches(:projects, :archived, true)
+ expect(Project.where(import_error: 'foo').count).to eq(5)
+ end
- expect(Project.where(archived: true).count).to eq(5)
- end
+ it 'updates boolean values correctly' do
+ model.update_column_in_batches(:projects, :archived, true)
+
+ expect(Project.where(archived: true).count).to eq(5)
+ end
+
+ context 'when a block is supplied' do
+ it 'yields an Arel table and query object to the supplied block' do
+ first_id = Project.first.id
- context 'when a block is supplied' do
- it 'yields an Arel table and query object to the supplied block' do
- first_id = Project.first.id
+ model.update_column_in_batches(:projects, :archived, true) do |t, query|
+ query.where(t[:id].eq(first_id))
+ end
- model.update_column_in_batches(:projects, :archived, true) do |t, query|
- query.where(t[:id].eq(first_id))
+ expect(Project.where(archived: true).count).to eq(1)
end
+ end
+
+ context 'when the value is Arel.sql (Arel::Nodes::SqlLiteral)' do
+ it 'updates the value as a SQL expression' do
+ model.update_column_in_batches(:projects, :star_count, Arel.sql('1+1'))
- expect(Project.where(archived: true).count).to eq(1)
+ expect(Project.sum(:star_count)).to eq(2 * Project.count)
+ end
end
end
- context 'when the value is Arel.sql (Arel::Nodes::SqlLiteral)' do
- it 'updates the value as a SQL expression' do
- model.update_column_in_batches(:projects, :star_count, Arel.sql('1+1'))
+ context 'when running inside the transaction' do
+ it 'raises RuntimeError' do
+ expect(model).to receive(:transaction_open?).and_return(true)
- expect(Project.sum(:star_count)).to eq(2 * Project.count)
+ expect do
+ model.update_column_in_batches(:projects, :star_count, Arel.sql('1+1'))
+ end.to raise_error(RuntimeError)
end
end
end
@@ -303,7 +317,9 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
context 'outside of a transaction' do
context 'when a column limit is not set' do
before do
- expect(model).to receive(:transaction_open?).and_return(false)
+ expect(model).to receive(:transaction_open?)
+ .and_return(false)
+ .at_least(:once)
expect(model).to receive(:transaction).and_yield
@@ -810,7 +826,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
let!(:user) { create(:user, name: 'Kathy Alice Aliceson') }
it 'replaces the correct part of the string' do
- model.update_column_in_batches(:users, :name, model.replace_sql(Arel::Table.new(:users)[:name], 'Alice', 'Eve'))
+ allow(model).to receive(:transaction_open?).and_return(false)
+ query = model.replace_sql(Arel::Table.new(:users)[:name], 'Alice', 'Eve')
+
+ model.update_column_in_batches(:users, :name, query)
+
expect(user.reload.name).to eq('Kathy Eve Aliceson')
end
end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
index a3ab4e3dd9e..5653cfee686 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase do
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :truncate do
let(:migration) { FakeRenameReservedPathMigrationV1.new }
let(:subject) { described_class.new(['the-path'], migration) }
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb
index aa63f6f9805..8125dedd3fc 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :truncate do
let(:migration) { FakeRenameReservedPathMigrationV1.new }
let(:subject) { described_class.new(['the-path'], migration) }
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb
index 9a6ed98898d..802f77ad430 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects do
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :truncate do
let(:migration) { FakeRenameReservedPathMigrationV1.new }
let(:subject) { described_class.new(['the-path'], migration) }
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
index bdd3af4ad44..1d5e58855c1 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
@@ -13,7 +13,7 @@ shared_examples 'renames child namespaces' do |type|
end
end
-describe Gitlab::Database::RenameReservedPathsMigration::V1 do
+describe Gitlab::Database::RenameReservedPathsMigration::V1, :truncate do
let(:subject) { FakeRenameReservedPathMigrationV1.new }
before do
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 5627562abfb..d50ccb0df30 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -175,6 +175,14 @@ EOT
expect(diff).to be_too_large
end
end
+
+ context 'when the patch passed is not UTF-8-encoded' do
+ let(:raw_patch) { @raw_diff_hash[:diff].encode(Encoding::ASCII_8BIT) }
+
+ it 'encodes diff patch to UTF-8' do
+ expect(diff.diff.encoding).to eq(Encoding::UTF_8)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb b/spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb
new file mode 100644
index 00000000000..61d48b05454
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb
@@ -0,0 +1,246 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::AdditionalMetricsParser, lib: true do
+ include Prometheus::MetricBuilders
+
+ let(:parser_error_class) { Gitlab::Prometheus::ParsingError }
+
+ describe '#load_groups_from_yaml' do
+ subject { described_class.load_groups_from_yaml }
+
+ describe 'parsing sample yaml' do
+ let(:sample_yaml) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: "title"
+ required_metrics: [ metric_a, metric_b ]
+ weight: 1
+ queries: [{ query_range: 'query_range_a', label: label, unit: unit }]
+ - title: "title"
+ required_metrics: [metric_a]
+ weight: 1
+ queries: [{ query_range: 'query_range_empty' }]
+ - group: group_b
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: ['metric_a']
+ weight: 1
+ queries: [{query_range: query_range_a}]
+ EOF
+ end
+
+ before do
+ allow(described_class).to receive(:load_yaml_file) { YAML.load(sample_yaml) }
+ end
+
+ it 'parses to two metric groups with 2 and 1 metric respectively' do
+ expect(subject.count).to eq(2)
+ expect(subject[0].metrics.count).to eq(2)
+ expect(subject[1].metrics.count).to eq(1)
+ end
+
+ it 'provide group data' do
+ expect(subject[0]).to have_attributes(name: 'group_a', priority: 1)
+ expect(subject[1]).to have_attributes(name: 'group_b', priority: 1)
+ end
+
+ it 'provides metrics data' do
+ metrics = subject.flat_map(&:metrics)
+
+ expect(metrics.count).to eq(3)
+ expect(metrics[0]).to have_attributes(title: 'title', required_metrics: %w(metric_a metric_b), weight: 1)
+ expect(metrics[1]).to have_attributes(title: 'title', required_metrics: %w(metric_a), weight: 1)
+ expect(metrics[2]).to have_attributes(title: 'title', required_metrics: %w{metric_a}, weight: 1)
+ end
+
+ it 'provides query data' do
+ queries = subject.flat_map(&:metrics).flat_map(&:queries)
+
+ expect(queries.count).to eq(3)
+ expect(queries[0]).to eq(query_range: 'query_range_a', label: 'label', unit: 'unit')
+ expect(queries[1]).to eq(query_range: 'query_range_empty')
+ expect(queries[2]).to eq(query_range: 'query_range_a')
+ end
+ end
+
+ shared_examples 'required field' do |field_name|
+ context "when #{field_name} is nil" do
+ before do
+ allow(described_class).to receive(:load_yaml_file) { YAML.load(field_missing) }
+ end
+
+ it 'throws parsing error' do
+ expect { subject }.to raise_error(parser_error_class, /#{field_name} can't be blank/i)
+ end
+ end
+
+ context "when #{field_name} are not specified" do
+ before do
+ allow(described_class).to receive(:load_yaml_file) { YAML.load(field_nil) }
+ end
+
+ it 'throws parsing error' do
+ expect { subject }.to raise_error(parser_error_class, /#{field_name} can't be blank/i)
+ end
+ end
+ end
+
+ describe 'group required fields' do
+ it_behaves_like 'required field', 'metrics' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'name' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group:
+ priority: 1
+ metrics: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - priority: 1
+ metrics: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'priority' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority:
+ metrics: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ metrics: []
+ EOF
+ end
+ end
+ end
+
+ describe 'metrics fields parsing' do
+ it_behaves_like 'required field', 'title' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title:
+ required_metrics: []
+ weight: 1
+ queries: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - required_metrics: []
+ weight: 1
+ queries: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'required metrics' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics:
+ weight: 1
+ queries: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ weight: 1
+ queries: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'weight' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ weight:
+ queries: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ queries: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', :queries do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ weight: 1
+ queries:
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ weight: 1
+ EOF
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
new file mode 100644
index 00000000000..4909aec5a4d
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery, lib: true do
+ include Prometheus::MetricBuilders
+
+ let(:client) { double('prometheus_client') }
+ let(:environment) { create(:environment, slug: 'environment-slug') }
+ let(:deployment) { create(:deployment, environment: environment) }
+
+ subject(:query_result) { described_class.new(client).query(deployment.id) }
+
+ around do |example|
+ Timecop.freeze(Time.local(2008, 9, 1, 12, 0, 0)) { example.run }
+ end
+
+ include_examples 'additional metrics query' do
+ it 'queries using specific time' do
+ expect(client).to receive(:query_range).with(anything,
+ start: (deployment.created_at - 30.minutes).to_f,
+ stop: (deployment.created_at + 30.minutes).to_f)
+
+ expect(query_result).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
new file mode 100644
index 00000000000..8e6e3bb5946
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery, lib: true do
+ include Prometheus::MetricBuilders
+
+ let(:client) { double('prometheus_client') }
+ let(:environment) { create(:environment, slug: 'environment-slug') }
+
+ subject(:query_result) { described_class.new(client).query(environment.id) }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ include_examples 'additional metrics query' do
+ it 'queries using specific time' do
+ expect(client).to receive(:query_range).with(anything, start: 8.hours.ago.to_f, stop: Time.now.to_f)
+ expect(query_result).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb b/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb
new file mode 100644
index 00000000000..d2796ab72da
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb
@@ -0,0 +1,134 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::MatchedMetricsQuery, lib: true do
+ include Prometheus::MetricBuilders
+
+ let(:metric_group_class) { Gitlab::Prometheus::MetricGroup }
+ let(:metric_class) { Gitlab::Prometheus::Metric }
+
+ def series_info_with_environment(*more_metrics)
+ %w{metric_a metric_b}.concat(more_metrics).map { |metric_name| { '__name__' => metric_name, 'environment' => '' } }
+ end
+
+ let(:metric_names) { %w{metric_a metric_b} }
+ let(:series_info_without_environment) do
+ [{ '__name__' => 'metric_a' },
+ { '__name__' => 'metric_b' }]
+ end
+ let(:partialy_empty_series_info) { [{ '__name__' => 'metric_a', 'environment' => '' }] }
+ let(:empty_series_info) { [] }
+
+ let(:client) { double('prometheus_client') }
+
+ subject { described_class.new(client) }
+
+ context 'with one group where two metrics is found' do
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group])
+ allow(client).to receive(:label_values).and_return(metric_names)
+ end
+
+ context 'both metrics in the group pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_with_environment)
+ end
+
+ it 'responds with both metrics as actve' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 2, metrics_missing_requirements: 0 }])
+ end
+ end
+
+ context 'none of the metrics pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_without_environment)
+ end
+
+ it 'responds with both metrics missing requirements' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 2 }])
+ end
+ end
+
+ context 'no series information found about the metrics' do
+ before do
+ allow(client).to receive(:series).and_return(empty_series_info)
+ end
+
+ it 'responds with both metrics missing requirements' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 2 }])
+ end
+ end
+
+ context 'one of the series info was not found' do
+ before do
+ allow(client).to receive(:series).and_return(partialy_empty_series_info)
+ end
+ it 'responds with one active and one missing metric' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 1, metrics_missing_requirements: 1 }])
+ end
+ end
+ end
+
+ context 'with one group where only one metric is found' do
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group])
+ allow(client).to receive(:label_values).and_return('metric_a')
+ end
+
+ context 'both metrics in the group pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_with_environment)
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 1, metrics_missing_requirements: 0 }])
+ end
+ end
+
+ context 'no metrics in group pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_without_environment)
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 1 }])
+ end
+ end
+ end
+
+ context 'with two groups where metrics are found in each group' do
+ let(:second_metric_group) { simple_metric_group(name: 'nameb', metrics: simple_metrics(added_metric_name: 'metric_c')) }
+
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group, second_metric_group])
+ allow(client).to receive(:label_values).and_return('metric_c')
+ end
+
+ context 'all metrics in both groups pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_with_environment('metric_c'))
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([
+ { group: 'name', priority: 1, active_metrics: 1, metrics_missing_requirements: 0 },
+ { group: 'nameb', priority: 1, active_metrics: 2, metrics_missing_requirements: 0 }
+ ]
+ )
+ end
+ end
+
+ context 'no metrics in groups pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_without_environment)
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([
+ { group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 1 },
+ { group: 'nameb', priority: 1, active_metrics: 0, metrics_missing_requirements: 2 }
+ ]
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb
index 2d8bd2f6b97..46eaadae206 100644
--- a/spec/lib/gitlab/prometheus_client_spec.rb
+++ b/spec/lib/gitlab/prometheus_client_spec.rb
@@ -119,6 +119,36 @@ describe Gitlab::PrometheusClient, lib: true do
end
end
+ describe '#series' do
+ let(:query_url) { prometheus_series_url('series_name', 'other_service') }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ it 'calls endpoint and returns list of series' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_series('series_name'))
+ expected = prometheus_series('series_name').deep_stringify_keys['data']
+
+ expect(subject.series('series_name', 'other_service')).to eq(expected)
+
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ describe '#label_values' do
+ let(:query_url) { prometheus_label_values_url('__name__') }
+
+ it 'calls endpoint and returns label values' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_label_values)
+ expected = prometheus_label_values.deep_stringify_keys['data']
+
+ expect(subject.label_values('__name__')).to eq(expected)
+
+ expect(req_stub).to have_been_requested
+ end
+ end
+
describe '#query_range' do
let(:prometheus_query) { prometheus_memory_query('env-slug') }
let(:query_url) { prometheus_query_range_url(prometheus_query) }
diff --git a/spec/lib/gitlab/visibility_level_spec.rb b/spec/lib/gitlab/visibility_level_spec.rb
index 84d2484cc8a..db9d2807be6 100644
--- a/spec/lib/gitlab/visibility_level_spec.rb
+++ b/spec/lib/gitlab/visibility_level_spec.rb
@@ -21,7 +21,7 @@ describe Gitlab::VisibilityLevel, lib: true do
describe '.levels_for_user' do
it 'returns all levels for an admin' do
- user = double(:user, admin?: true)
+ user = build(:user, :admin)
expect(described_class.levels_for_user(user))
.to eq([Gitlab::VisibilityLevel::PRIVATE,
@@ -30,7 +30,7 @@ describe Gitlab::VisibilityLevel, lib: true do
end
it 'returns INTERNAL and PUBLIC for internal users' do
- user = double(:user, admin?: false, external?: false)
+ user = build(:user)
expect(described_class.levels_for_user(user))
.to eq([Gitlab::VisibilityLevel::INTERNAL,
@@ -38,7 +38,7 @@ describe Gitlab::VisibilityLevel, lib: true do
end
it 'returns PUBLIC for external users' do
- user = double(:user, admin?: false, external?: true)
+ user = build(:user, :external)
expect(described_class.levels_for_user(user))
.to eq([Gitlab::VisibilityLevel::PUBLIC])
diff --git a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
index bd5f85b901d..65bea662b02 100644
--- a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
+++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb')
-describe AddHeadPipelineForEachMergeRequest do
+describe AddHeadPipelineForEachMergeRequest, :truncate do
let(:migration) { described_class.new }
let!(:project) { create(:empty_project) }
diff --git a/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb b/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb
index 1db9bc002ae..e3b42b5eac8 100644
--- a/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb
+++ b/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170324160416_migrate_user_activities_to_users_last_activity_on.rb')
-describe MigrateUserActivitiesToUsersLastActivityOn, :redis do
+describe MigrateUserActivitiesToUsersLastActivityOn, :redis, :truncate do
let(:migration) { described_class.new }
let!(:user_active_1) { create(:user) }
let!(:user_active_2) { create(:user) }
diff --git a/spec/migrations/migrate_user_project_view_spec.rb b/spec/migrations/migrate_user_project_view_spec.rb
index 70f8e0d6082..afaa5d836a7 100644
--- a/spec/migrations/migrate_user_project_view_spec.rb
+++ b/spec/migrations/migrate_user_project_view_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170406142253_migrate_user_project_view.rb')
-describe MigrateUserProjectView do
+describe MigrateUserProjectView, :truncate do
let(:migration) { described_class.new }
let!(:user) { create(:user) }
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index aad215d5f41..bb84d3fc13d 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -30,7 +30,7 @@ describe Deployment, models: true do
end
describe '#includes_commit?' do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository) }
let(:environment) { create(:environment, project: project) }
let(:deployment) do
create(:deployment, environment: environment, sha: project.commit.id)
@@ -90,6 +90,36 @@ describe Deployment, models: true do
end
end
+ describe '#additional_metrics' do
+ let(:project) { create(:project) }
+ let(:deployment) { create(:deployment, project: project) }
+
+ subject { deployment.additional_metrics }
+
+ context 'metrics are disabled' do
+ it { is_expected.to eq({}) }
+ end
+
+ context 'metrics are enabled' do
+ let(:simple_metrics) do
+ {
+ success: true,
+ metrics: {},
+ last_update: 42
+ }
+ end
+
+ let(:prometheus_service) { double('prometheus_service') }
+
+ before do
+ allow(project).to receive(:prometheus_service).and_return(prometheus_service)
+ allow(prometheus_service).to receive(:additional_deployment_metrics).and_return(simple_metrics)
+ end
+
+ it { is_expected.to eq(simple_metrics.merge({ deployment_time: deployment.created_at.to_i })) }
+ end
+ end
+
describe '#stop_action' do
let(:build) { create(:ci_build) }
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index f8123cb518e..b0635c6a90a 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -432,6 +432,99 @@ describe Environment, models: true do
end
end
+ describe '#has_metrics?' do
+ subject { environment.has_metrics? }
+
+ context 'when the enviroment is available' do
+ context 'with a deployment service' do
+ let(:project) { create(:prometheus_project) }
+
+ context 'and a deployment' do
+ let!(:deployment) { create(:deployment, environment: environment) }
+ it { is_expected.to be_truthy }
+ end
+
+ context 'but no deployments' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'without a monitoring service' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'when the environment is unavailable' do
+ let(:project) { create(:prometheus_project) }
+
+ before do
+ environment.stop
+ end
+
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ describe '#additional_metrics' do
+ let(:project) { create(:prometheus_project) }
+ subject { environment.additional_metrics }
+
+ context 'when the environment has additional metrics' do
+ before do
+ allow(environment).to receive(:has_additional_metrics?).and_return(true)
+ end
+
+ it 'returns the additional metrics from the deployment service' do
+ expect(project.prometheus_service).to receive(:additional_environment_metrics)
+ .with(environment)
+ .and_return(:fake_metrics)
+
+ is_expected.to eq(:fake_metrics)
+ end
+ end
+
+ context 'when the environment does not have metrics' do
+ before do
+ allow(environment).to receive(:has_additional_metrics?).and_return(false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#has_additional_metrics??' do
+ subject { environment.has_additional_metrics? }
+
+ context 'when the enviroment is available' do
+ context 'with a deployment service' do
+ let(:project) { create(:prometheus_project) }
+
+ context 'and a deployment' do
+ let!(:deployment) { create(:deployment, environment: environment) }
+ it { is_expected.to be_truthy }
+ end
+
+ context 'but no deployments' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'without a monitoring service' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'when the environment is unavailable' do
+ let(:project) { create(:prometheus_project) }
+
+ before do
+ environment.stop
+ end
+
+ it { is_expected.to be_falsy }
+ end
+ end
+
describe '#slug' do
it "is automatically generated" do
expect(environment.slug).not_to be_nil
diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb
index 71b53732164..c914b32f7a4 100644
--- a/spec/models/project_services/prometheus_service_spec.rb
+++ b/spec/models/project_services/prometheus_service_spec.rb
@@ -65,7 +65,7 @@ describe PrometheusService, models: true, caching: true do
end
it 'returns reactive data' do
- is_expected.to eq(prometheus_data)
+ is_expected.to eq(prometheus_metrics_data)
end
end
end
@@ -86,7 +86,7 @@ describe PrometheusService, models: true, caching: true do
end
it 'returns reactive data' do
- is_expected.to eq(prometheus_data.merge(deployment_time: deployment.created_at.to_i))
+ is_expected.to eq(prometheus_metrics_data.merge(deployment_time: deployment.created_at.to_i))
end
end
end
@@ -116,6 +116,7 @@ describe PrometheusService, models: true, caching: true do
end
it { expect(subject.to_json).to eq(prometheus_data.to_json) }
+ it { expect(subject.to_json).to eq(prometheus_data.to_json) }
end
[404, 500].each do |status|
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 314f8781867..8e895ec6634 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1733,6 +1733,20 @@ describe User, models: true do
end
end
+ describe '#full_private_access?' do
+ it 'returns false for regular user' do
+ user = build(:user)
+
+ expect(user.full_private_access?).to be_falsy
+ end
+
+ it 'returns true for admin user' do
+ user = build(:user, :admin)
+
+ expect(user.full_private_access?).to be_truthy
+ end
+ end
+
describe '.ghost' do
it "creates a ghost user if one isn't already present" do
ghost = User.ghost
diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb
index e42d727672b..dff0dfba675 100644
--- a/spec/support/api/schema_matcher.rb
+++ b/spec/support/api/schema_matcher.rb
@@ -1,8 +1,16 @@
+def schema_path(schema)
+ schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas"
+ "#{schema_directory}/#{schema}.json"
+end
+
RSpec::Matchers.define :match_response_schema do |schema, **options|
match do |response|
- schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas"
- schema_path = "#{schema_directory}/#{schema}.json"
+ JSON::Validator.validate!(schema_path(schema), response.body, options)
+ end
+end
- JSON::Validator.validate!(schema_path, response.body, options)
+RSpec::Matchers.define :match_schema do |schema, **options|
+ match do |data|
+ JSON::Validator.validate!(schema_path(schema), data, options)
end
end
diff --git a/spec/support/prometheus/additional_metrics_shared_examples.rb b/spec/support/prometheus/additional_metrics_shared_examples.rb
new file mode 100644
index 00000000000..016e16fc8d4
--- /dev/null
+++ b/spec/support/prometheus/additional_metrics_shared_examples.rb
@@ -0,0 +1,101 @@
+RSpec.shared_examples 'additional metrics query' do
+ include Prometheus::MetricBuilders
+
+ let(:metric_group_class) { Gitlab::Prometheus::MetricGroup }
+ let(:metric_class) { Gitlab::Prometheus::Metric }
+
+ let(:metric_names) { %w{metric_a metric_b} }
+
+ let(:query_range_result) do
+ [{ 'metric': {}, 'values': [[1488758662.506, '0.00002996364761904785'], [1488758722.506, '0.00003090239047619091']] }]
+ end
+
+ before do
+ allow(client).to receive(:label_values).and_return(metric_names)
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group(metrics: [simple_metric])])
+ end
+
+ context 'with one group where two metrics is found' do
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group])
+ end
+
+ context 'some queries return results' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_empty', any_args).and_return([])
+ end
+
+ it 'return group data only for queries with results' do
+ expected = [
+ {
+ group: 'name',
+ priority: 1,
+ metrics: [
+ {
+ title: 'title', weight: 1, y_label: 'Values', queries: [
+ { query_range: 'query_range_a', result: query_range_result },
+ { query_range: 'query_range_b', label: 'label', unit: 'unit', result: query_range_result }
+ ]
+ }
+ ]
+ }
+ ]
+
+ expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
+ expect(query_result).to eq(expected)
+ end
+ end
+ end
+
+ context 'with two groups with one metric each' do
+ let(:metrics) { [simple_metric(queries: [simple_query])] }
+ before do
+ allow(metric_group_class).to receive(:all).and_return(
+ [
+ simple_metric_group(name: 'group_a', metrics: [simple_metric(queries: [simple_query])]),
+ simple_metric_group(name: 'group_b', metrics: [simple_metric(title: 'title_b', queries: [simple_query('b')])])
+ ])
+ allow(client).to receive(:label_values).and_return(metric_names)
+ end
+
+ context 'both queries return results' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
+ end
+
+ it 'return group data both queries' do
+ queries_with_result_a = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
+ queries_with_result_b = { queries: [{ query_range: 'query_range_b', result: query_range_result }] }
+
+ expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
+
+ expect(query_result.count).to eq(2)
+ expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
+
+ expect(query_result[0][:metrics].first).to include(queries_with_result_a)
+ expect(query_result[1][:metrics].first).to include(queries_with_result_b)
+ end
+ end
+
+ context 'one query returns result' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return([])
+ end
+
+ it 'return group data only for query with results' do
+ queries_with_result = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
+
+ expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
+
+ expect(query_result.count).to eq(1)
+ expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
+
+ expect(query_result.first[:metrics].first).to include(queries_with_result)
+ end
+ end
+ end
+end
diff --git a/spec/support/prometheus/metric_builders.rb b/spec/support/prometheus/metric_builders.rb
new file mode 100644
index 00000000000..c8d056d3fc8
--- /dev/null
+++ b/spec/support/prometheus/metric_builders.rb
@@ -0,0 +1,27 @@
+module Prometheus
+ module MetricBuilders
+ def simple_query(suffix = 'a', **opts)
+ { query_range: "query_range_#{suffix}" }.merge(opts)
+ end
+
+ def simple_queries
+ [simple_query, simple_query('b', label: 'label', unit: 'unit')]
+ end
+
+ def simple_metric(title: 'title', required_metrics: [], queries: [simple_query])
+ Gitlab::Prometheus::Metric.new(title: title, required_metrics: required_metrics, weight: 1, queries: queries)
+ end
+
+ def simple_metrics(added_metric_name: 'metric_a')
+ [
+ simple_metric(required_metrics: %W(#{added_metric_name} metric_b), queries: simple_queries),
+ simple_metric(required_metrics: [added_metric_name], queries: [simple_query('empty')]),
+ simple_metric(required_metrics: %w{metric_c})
+ ]
+ end
+
+ def simple_metric_group(name: 'name', metrics: simple_metrics)
+ Gitlab::Prometheus::MetricGroup.new(name: name, priority: 1, metrics: metrics)
+ end
+ end
+end
diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb
index 6b9ebcf2bb3..4212be2cc88 100644
--- a/spec/support/prometheus_helpers.rb
+++ b/spec/support/prometheus_helpers.rb
@@ -36,6 +36,19 @@ module PrometheusHelpers
"https://prometheus.example.com/api/v1/query_range?#{query}"
end
+ def prometheus_label_values_url(name)
+ "https://prometheus.example.com/api/v1/label/#{name}/values"
+ end
+
+ def prometheus_series_url(*matches, start: 8.hours.ago, stop: Time.now)
+ query = {
+ match: matches,
+ start: start.to_f,
+ end: stop.to_f
+ }.to_query
+ "https://prometheus.example.com/api/v1/series?#{query}"
+ end
+
def stub_prometheus_request(url, body: {}, status: 200)
WebMock.stub_request(:get, url)
.to_return({
@@ -85,6 +98,19 @@ module PrometheusHelpers
def prometheus_data(last_update: Time.now.utc)
{
success: true,
+ data: {
+ memory_values: prometheus_values_body('matrix').dig(:data, :result),
+ memory_current: prometheus_value_body('vector').dig(:data, :result),
+ cpu_values: prometheus_values_body('matrix').dig(:data, :result),
+ cpu_current: prometheus_value_body('vector').dig(:data, :result)
+ },
+ last_update: last_update
+ }
+ end
+
+ def prometheus_metrics_data(last_update: Time.now.utc)
+ {
+ success: true,
metrics: {
memory_values: prometheus_values_body('matrix').dig(:data, :result),
memory_current: prometheus_value_body('vector').dig(:data, :result),
@@ -140,4 +166,37 @@ module PrometheusHelpers
}
}
end
+
+ def prometheus_label_values
+ {
+ 'status': 'success',
+ 'data': %w(job_adds job_controller_rate_limiter_use job_depth job_queue_latency job_work_duration_sum up)
+ }
+ end
+
+ def prometheus_series(name)
+ {
+ 'status': 'success',
+ 'data': [
+ {
+ '__name__': name,
+ 'container_name': 'gitlab',
+ 'environment': 'mattermost',
+ 'id': '/docker/9953982f95cf5010dfc59d7864564d5f188aaecddeda343699783009f89db667',
+ 'image': 'gitlab/gitlab-ce:8.15.4-ce.1',
+ 'instance': 'minikube',
+ 'job': 'kubernetes-nodes',
+ 'name': 'k8s_gitlab.e6611886_mattermost-4210310111-77z8r_gitlab_2298ae6b-da24-11e6-baee-8e7f67d0eb3a_43536cb6',
+ 'namespace': 'gitlab',
+ 'pod_name': 'mattermost-4210310111-77z8r'
+ },
+ {
+ '__name__': name,
+ 'id': '/docker',
+ 'instance': 'minikube',
+ 'job': 'kubernetes-nodes'
+ }
+ ]
+ }
+ end
end
diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb
index 122075cc10e..92b4aa12d49 100644
--- a/spec/views/projects/commit/show.html.haml_spec.rb
+++ b/spec/views/projects/commit/show.html.haml_spec.rb
@@ -21,24 +21,26 @@ describe 'projects/commit/show.html.haml', :view do
context 'inline diff view' do
before do
allow(view).to receive(:diff_view).and_return(:inline)
+ allow(view).to receive(:diff_view).and_return(:inline)
render
end
- it 'keeps container-limited' do
- expect(rendered).not_to have_selector('.limit-container-width')
+ it 'has limited width' do
+ expect(rendered).to have_selector('.limit-container-width')
end
end
context 'parallel diff view' do
before do
allow(view).to receive(:diff_view).and_return(:parallel)
+ allow(view).to receive(:fluid_layout).and_return(true)
render
end
it 'spans full width' do
- expect(rendered).to have_selector('.limit-container-width')
+ expect(rendered).not_to have_selector('.limit-container-width')
end
end
end