From 8a7efa45c38ed3200d173d2c3207a8154e583c16 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 2 Apr 2020 18:08:11 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .rubocop.yml | 1 - app/assets/javascripts/boards/components/board.js | 15 + .../javascripts/boards/components/board_column.vue | 384 +++++++++++++++++++++ app/assets/javascripts/boards/index.js | 11 +- app/assets/javascripts/dropzone_input.js | 13 +- app/assets/javascripts/lib/utils/file_upload.js | 11 + .../notes/components/diff_with_note.vue | 2 +- app/controllers/groups/boards_controller.rb | 1 + app/controllers/projects/boards_controller.rb | 1 + .../container_expiration_policies_helper.rb | 2 +- app/models/ci/legacy_stage.rb | 2 +- app/models/ci/pipeline.rb | 2 +- app/models/ci/stage.rb | 2 +- app/models/commit_status.rb | 8 +- app/models/concerns/has_status.rb | 4 +- app/models/user.rb | 2 +- .../legacy_processing_service.rb | 4 +- app/views/shared/boards/_show.html.haml | 6 + .../shared/boards/components/_board.html.haml | 3 + ...y-copy-pasting-the-file-into-the-design-tab.yml | 5 + ...208268-enable-container-policies-by-default.yml | 5 + changelogs/unreleased/refactor-service-spec.yml | 5 + ...ble_container_expiration_policies_by_default.rb | 19 + db/structure.sql | 3 +- doc/api/README.md | 18 + doc/development/README.md | 4 +- doc/development/api_styleguide.md | 2 +- doc/development/database_review.md | 1 + doc/development/migration_style_guide.md | 88 ++++- doc/install/aws/index.md | 25 +- doc/install/installation.md | 9 + doc/user/project/issues/design_management.md | 13 + lib/gitlab/sidekiq_middleware/metrics.rb | 8 +- lib/support/init.d/gitlab | 13 +- .../projects/settings/registry_settings_spec.rb | 1 - spec/frontend/__mocks__/sortablejs/index.js | 2 +- .../boards/components/board_column_spec.js | 172 +++++++++ spec/frontend/boards/list_spec.js | 5 +- spec/frontend/boards/mock_data.js | 4 +- spec/frontend/lib/utils/file_upload_spec.js | 14 +- .../components/diff_discussion_header_spec.js | 2 +- .../notes/components/diff_with_note_spec.js | 86 +++++ .../notes/components/discussion_filter_spec.js | 219 ++++++++++++ .../discussion_resolve_with_issue_button_spec.js | 30 ++ .../components/note_actions/reply_button_spec.js | 29 ++ .../frontend/notes/components/note_actions_spec.js | 160 +++++++++ .../notes/components/note_awards_list_spec.js | 163 +++++++++ spec/frontend/notes/components/note_body_spec.js | 57 +++ spec/frontend/notes/components/note_form_spec.js | 248 +++++++++++++ .../components/note_signed_out_widget_spec.js | 41 +++ .../notes/components/noteable_discussion_spec.js | 187 ++++++++++ .../notes/components/noteable_note_spec.js | 137 ++++++++ .../notes/components/toggle_replies_widget_spec.js | 78 +++++ spec/frontend/notes/stores/collapse_utils_spec.js | 37 ++ .../container_expiration_policies_helper_spec.rb | 4 +- .../javascripts/helpers/init_vue_mr_page_helper.js | 2 +- .../notes/components/diff_with_note_spec.js | 89 ----- .../notes/components/discussion_filter_spec.js | 187 ---------- .../discussion_resolve_with_issue_button_spec.js | 30 -- .../components/note_actions/reply_button_spec.js | 29 -- .../notes/components/note_actions_spec.js | 160 --------- .../notes/components/note_awards_list_spec.js | 152 -------- .../javascripts/notes/components/note_body_spec.js | 57 --- .../javascripts/notes/components/note_form_spec.js | 267 -------------- .../components/note_signed_out_widget_spec.js | 41 --- .../notes/components/noteable_discussion_spec.js | 195 ----------- .../notes/components/noteable_note_spec.js | 137 -------- .../notes/components/toggle_replies_widget_spec.js | 78 ----- spec/javascripts/notes/helpers.js | 1 - spec/javascripts/notes/mock_data.js | 1 - .../notes/stores/collapse_utils_spec.js | 37 -- .../sidekiq_middleware/client_metrics_spec.rb | 9 +- .../sidekiq_middleware/server_metrics_spec.rb | 9 +- spec/models/ci/pipeline_spec.rb | 2 +- spec/models/commit_status_spec.rb | 6 +- spec/models/concerns/has_status_spec.rb | 2 +- spec/models/user_spec.rb | 6 + 77 files changed, 2331 insertions(+), 1534 deletions(-) create mode 100644 app/assets/javascripts/boards/components/board_column.vue create mode 100644 changelogs/unreleased/202634-upload-a-design-by-copy-pasting-the-file-into-the-design-tab.yml create mode 100644 changelogs/unreleased/208268-enable-container-policies-by-default.yml create mode 100644 changelogs/unreleased/refactor-service-spec.yml create mode 100644 db/migrate/20200331220930_enable_container_expiration_policies_by_default.rb create mode 100644 spec/frontend/boards/components/board_column_spec.js create mode 100644 spec/frontend/notes/components/diff_with_note_spec.js create mode 100644 spec/frontend/notes/components/discussion_filter_spec.js create mode 100644 spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js create mode 100644 spec/frontend/notes/components/note_actions/reply_button_spec.js create mode 100644 spec/frontend/notes/components/note_actions_spec.js create mode 100644 spec/frontend/notes/components/note_awards_list_spec.js create mode 100644 spec/frontend/notes/components/note_body_spec.js create mode 100644 spec/frontend/notes/components/note_form_spec.js create mode 100644 spec/frontend/notes/components/note_signed_out_widget_spec.js create mode 100644 spec/frontend/notes/components/noteable_discussion_spec.js create mode 100644 spec/frontend/notes/components/noteable_note_spec.js create mode 100644 spec/frontend/notes/components/toggle_replies_widget_spec.js create mode 100644 spec/frontend/notes/stores/collapse_utils_spec.js delete mode 100644 spec/javascripts/notes/components/diff_with_note_spec.js delete mode 100644 spec/javascripts/notes/components/discussion_filter_spec.js delete mode 100644 spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js delete mode 100644 spec/javascripts/notes/components/note_actions/reply_button_spec.js delete mode 100644 spec/javascripts/notes/components/note_actions_spec.js delete mode 100644 spec/javascripts/notes/components/note_awards_list_spec.js delete mode 100644 spec/javascripts/notes/components/note_body_spec.js delete mode 100644 spec/javascripts/notes/components/note_form_spec.js delete mode 100644 spec/javascripts/notes/components/note_signed_out_widget_spec.js delete mode 100644 spec/javascripts/notes/components/noteable_discussion_spec.js delete mode 100644 spec/javascripts/notes/components/noteable_note_spec.js delete mode 100644 spec/javascripts/notes/components/toggle_replies_widget_spec.js delete mode 100644 spec/javascripts/notes/helpers.js delete mode 100644 spec/javascripts/notes/mock_data.js delete mode 100644 spec/javascripts/notes/stores/collapse_utils_spec.js diff --git a/.rubocop.yml b/.rubocop.yml index 7474c87dafe..1418a72c88a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -409,5 +409,4 @@ RSpec/RepeatedExample: - 'spec/rubocop/cop/migration/update_large_table_spec.rb' - 'spec/services/notification_service_spec.rb' - 'spec/services/web_hook_service_spec.rb' - - 'ee/spec/services/boards/lists/update_service_spec.rb' - 'ee/spec/services/geo/repository_verification_primary_service_spec.rb' diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index b68a6ad0ef5..48f5614cefe 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -16,6 +16,14 @@ import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_ import { ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; +/** + * Please don't edit this file, have a look at: + * ./board_column.vue + * https://gitlab.com/gitlab-org/gitlab/-/issues/212300 + * + * This file here will be deleted soon + * @deprecated + */ export default Vue.extend({ components: { BoardBlankState, @@ -54,6 +62,13 @@ export default Vue.extend({ type: String, required: true, }, + // Does not do anything but is used + // to support the API of the new board_column.vue + canAdminList: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue new file mode 100644 index 00000000000..693b1f9d7b1 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -0,0 +1,384 @@ + + + diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 781cb0c1cc9..a12db7a5f1a 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,7 +3,6 @@ import Vue from 'vue'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; -import Board from 'ee_else_ce/boards/components/board'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; @@ -65,7 +64,15 @@ export default () => { issueBoardsApp = new Vue({ el: $boardApp, components: { - Board, + Board: () => + window?.gon?.features?.sfcIssueBoards + ? import('ee_else_ce/boards/components/board_column.vue') + : /** + * Please have a look at, we are moving to the SFC soon: + * https://gitlab.com/gitlab-org/gitlab/-/issues/212300 + * @deprecated + */ + import('ee_else_ce/boards/components/board'), BoardSidebar, BoardAddIssuesModal, BoardSettingsSidebar: () => diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 0e2dd59092a..6c158ad8990 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -6,6 +6,7 @@ import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; import csrf from './lib/utils/csrf'; import axios from './lib/utils/axios_utils'; import { n__, __ } from '~/locale'; +import { getFilename } from '~/lib/utils/file_upload'; Dropzone.autoDiscover = false; @@ -41,7 +42,6 @@ export default function dropzoneInput(form) { let addFileToForm; let updateAttachingMessage; let isImage; - let getFilename; let uploadFile; formTextarea.wrap('
'); @@ -235,17 +235,6 @@ export default function dropzoneInput(form) { $(form).append(``); }; - getFilename = e => { - let value; - if (window.clipboardData && window.clipboardData.getData) { - value = window.clipboardData.getData('Text'); - } else if (e.clipboardData && e.clipboardData.getData) { - value = e.clipboardData.getData('text/plain'); - } - value = value.split('\r'); - return value[0]; - }; - const showSpinner = () => $uploadingProgressContainer.removeClass('hide'); const closeSpinner = () => $uploadingProgressContainer.addClass('hide'); diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index 82ee83e4348..b8b63bf58d4 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -14,3 +14,14 @@ export default (buttonSelector, fileSelector) => { form.querySelector('.js-filename').textContent = fileInput.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape }); }; + +export const getFilename = ({ clipboardData }) => { + let value; + if (window.clipboardData && window.clipboardData.getData) { + value = window.clipboardData.getData('Text'); + } else if (clipboardData && clipboardData.getData) { + value = clipboardData.getData('text/plain'); + } + value = value.split('\r'); + return value[0]; +}; diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index c3915ef299b..a58a040fb4e 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -48,7 +48,7 @@ export default { }, }, mounted() { - if (!this.hasTruncatedDiffLines) { + if (this.isTextFile && !this.hasTruncatedDiffLines) { this.fetchDiff(); } }, diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index fab84fb8299..a9bd24890ee 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -8,6 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:multi_select_board, default_enabled: true) + push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true) end private diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index db05da0bb7f..8fa823e0be1 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:multi_select_board, default_enabled: true) + push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true) end private diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb index 5fb7b5afa6e..cc6d717ce35 100644 --- a/app/helpers/container_expiration_policies_helper.rb +++ b/app/helpers/container_expiration_policies_helper.rb @@ -20,7 +20,7 @@ module ContainerExpirationPoliciesHelper def older_than_options ContainerExpirationPolicy.older_than_options.map do |key, val| { key: key.to_s, label: val }.tap do |base| - base[:default] = true if key.to_s == '30d' + base[:default] = true if key.to_s == '90d' end end end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index 0a67a652e22..9ca5cf13907 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -32,7 +32,7 @@ module Ci end def status - @status ||= statuses.latest.slow_composite_status + @status ||= statuses.latest.slow_composite_status(project: project) end def detailed_status(current_user) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 4e9be86d3a9..3ce44a066ae 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -968,7 +968,7 @@ module Ci def latest_builds_status return 'failed' unless yaml_errors.blank? - statuses.latest.slow_composite_status || 'skipped' + statuses.latest.slow_composite_status(project: project) || 'skipped' end def keep_around_commits diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 75f73429c2a..e6c34f3df03 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -138,7 +138,7 @@ module Ci end def latest_stage_status - statuses.latest.slow_composite_status || 'skipped' + statuses.latest.slow_composite_status(project: project) || 'skipped' end end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 03260b28335..046f131b041 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -178,12 +178,12 @@ class CommitStatus < ApplicationRecord select(:name) end - def self.status_for_prior_stages(index) - before_stage(index).latest.slow_composite_status || 'success' + def self.status_for_prior_stages(index, project:) + before_stage(index).latest.slow_composite_status(project: project) || 'success' end - def self.status_for_names(names) - where(name: names).latest.slow_composite_status || 'success' + def self.status_for_names(names, project:) + where(name: names).latest.slow_composite_status(project: project) || 'success' end def self.update_as_processed! diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index e06dad38c32..b80f8c2bbb2 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -65,8 +65,8 @@ module HasStatus # This method performs expensive calculation of status: # 1. By plucking all related objects, # 2. Or executes expensive SQL query - def slow_composite_status - if Feature.enabled?(:ci_composite_status, default_enabled: false) + def slow_composite_status(project:) + if Feature.enabled?(:ci_composite_status, project, default_enabled: false) Gitlab::Ci::Status::Composite .new(all, with_allow_failure: columns_hash.key?('allow_failure')) .status diff --git a/app/models/user.rb b/app/models/user.rb index e18d642a155..4d450f9305f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1689,7 +1689,7 @@ class User < ApplicationRecord def gitlab_employee? strong_memoize(:gitlab_employee) do if Gitlab.com? - Mail::Address.new(email).domain == "gitlab.com" + Mail::Address.new(email).domain == "gitlab.com" && confirmed? else false end diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb index 278fba20283..8d7b80282fc 100644 --- a/app/services/ci/pipeline_processing/legacy_processing_service.rb +++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb @@ -89,11 +89,11 @@ module Ci end def status_for_prior_stages(index) - pipeline.processables.status_for_prior_stages(index) + pipeline.processables.status_for_prior_stages(index, project: pipeline.project) end def status_for_build_needs(needs) - pipeline.processables.status_for_names(needs) + pipeline.processables.status_for_names(needs, project: pipeline.project) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 7f62b983bfc..cf42ac3dd37 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -1,5 +1,9 @@ - board = local_assigns.fetch(:board, nil) - group = local_assigns.fetch(:group, false) +-# TODO: Move group_id and can_admin_list to the board store + See: https://gitlab.com/gitlab-org/gitlab/-/issues/213082 +- group_id = @group&.id || "null" +- can_admin_list = can?(current_user, :admin_list, current_board_parent) == true - @no_breadcrumb_container = true - @no_container = true - @content_class = "issue-boards-content js-focus-mode-board" @@ -22,6 +26,8 @@ %board{ "v-cloak" => "true", "v-for" => "list in state.lists", "ref" => "board", + ":can-admin-list" => can_admin_list, + ":group-id" => group_id, ":list" => "list", ":disabled" => "disabled", ":issue-link-base" => "issueLinkBase", diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index e42d8650708..f3f2c09ea61 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,3 +1,6 @@ +-# Please have a look at app/assets/javascripts/boards/components/board_column.vue + This haml file is deprecated and will be deleted soon, please change the Vue app + https://gitlab.com/gitlab-org/gitlab/-/issues/212300 .board.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', ":data-id" => "list.id", data: { qa_selector: "board_list" } } .board-inner.d-flex.flex-column.position-relative.h-100.rounded diff --git a/changelogs/unreleased/202634-upload-a-design-by-copy-pasting-the-file-into-the-design-tab.yml b/changelogs/unreleased/202634-upload-a-design-by-copy-pasting-the-file-into-the-design-tab.yml new file mode 100644 index 00000000000..834bb02c284 --- /dev/null +++ b/changelogs/unreleased/202634-upload-a-design-by-copy-pasting-the-file-into-the-design-tab.yml @@ -0,0 +1,5 @@ +--- +title: Upload a design by copy/pasting the file into the Design Tab +merge_request: 27776 +author: +type: added diff --git a/changelogs/unreleased/208268-enable-container-policies-by-default.yml b/changelogs/unreleased/208268-enable-container-policies-by-default.yml new file mode 100644 index 00000000000..eaf796a047b --- /dev/null +++ b/changelogs/unreleased/208268-enable-container-policies-by-default.yml @@ -0,0 +1,5 @@ +--- +title: Enable container expiration policies by default for new projects +merge_request: 28480 +author: +type: changed diff --git a/changelogs/unreleased/refactor-service-spec.yml b/changelogs/unreleased/refactor-service-spec.yml new file mode 100644 index 00000000000..ded09385fc5 --- /dev/null +++ b/changelogs/unreleased/refactor-service-spec.yml @@ -0,0 +1,5 @@ +--- +title: Remove duplicate specs in update service spec +merge_request: 28650 +author: Rajendra Kadam +type: added diff --git a/db/migrate/20200331220930_enable_container_expiration_policies_by_default.rb b/db/migrate/20200331220930_enable_container_expiration_policies_by_default.rb new file mode 100644 index 00000000000..46393416eb7 --- /dev/null +++ b/db/migrate/20200331220930_enable_container_expiration_policies_by_default.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class EnableContainerExpirationPoliciesByDefault < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + change_column_default :container_expiration_policies, :enabled, true + end + end + + def down + with_lock_retries do + change_column_default :container_expiration_policies, :enabled, false + end + end +end diff --git a/db/structure.sql b/db/structure.sql index bbd6312b08a..0d25712e0b4 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1842,7 +1842,7 @@ CREATE TABLE public.container_expiration_policies ( cadence character varying(12) DEFAULT '7d'::character varying NOT NULL, older_than character varying(12), keep_n integer, - enabled boolean DEFAULT false NOT NULL + enabled boolean DEFAULT true NOT NULL ); CREATE TABLE public.container_repositories ( @@ -12926,5 +12926,6 @@ COPY "schema_migrations" (version) FROM STDIN; 20200326145443 20200330074719 20200330132913 +20200331220930 \. diff --git a/doc/api/README.md b/doc/api/README.md index 319a697b082..24b81852dc5 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -436,6 +436,24 @@ Keyset-based pagination is only supported for selected resources and ordering op | ------------------------- | -------------------------- | | [Projects](projects.md) | `order_by=id` only | +## Path parameters + +If an endpoint has path parameters, the documentation shows them with a preceding colon. + +For example: + +```plaintext +DELETE /projects/:id/share/:group_id +``` + +The `:id` path parameter needs to be replaced with the project id, and the `:group_id` needs to be replaced with the id of the group. The colons `:` should not be included. + +The resulting cURL call for a project with id `5` and a group id of `17` is then: + +```shell +curl --request DELETE --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/projects/5/share/17 +``` + ## Namespaced path encoding If using namespaced API calls, make sure that the `NAMESPACE/PROJECT_PATH` is diff --git a/doc/development/README.md b/doc/development/README.md index 2089cf794ba..e55989e312a 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -98,6 +98,8 @@ Complementary reads: - [Application limits](application_limits.md) - [Redis guidelines](redis.md) - [Rails initializers](rails_initializers.md) +- [Code comments](code_comments.md) +- [Renaming features](renaming_features.md) ## Performance guides @@ -150,9 +152,7 @@ Complementary reads: - [Verifying database capabilities](verifying_database_capabilities.md) - [Database Debugging and Troubleshooting](database_debugging.md) - [Query Count Limits](query_count_limits.md) -- [Code comments](code_comments.md) - [Creating enums](creating_enums.md) -- [Renaming features](renaming_features.md) ### Case studies diff --git a/doc/development/api_styleguide.md b/doc/development/api_styleguide.md index 7c0146017b1..37d8a677389 100644 --- a/doc/development/api_styleguide.md +++ b/doc/development/api_styleguide.md @@ -121,7 +121,7 @@ For instance: The [internal API](./internal_api.md) is documented for internal use. Please keep it up to date so we know what endpoints different components are making use of. -[Entity]: https://gitlab.com/gitlab-org/gitlab/blob/master/lib/api/entities.rb +[Entity]: https://gitlab.com/gitlab-org/gitlab/blob/master/lib/api/entities [validation, and coercion of the parameters]: https://github.com/ruby-grape/grape#parameter-validation-and-coercion [installing GitLab under a relative URL]: https://docs.gitlab.com/ee/install/relative_url.html diff --git a/doc/development/database_review.md b/doc/development/database_review.md index 0fdf255e266..650c6fc7936 100644 --- a/doc/development/database_review.md +++ b/doc/development/database_review.md @@ -79,6 +79,7 @@ the following preparations into account. - Include either a rollback procedure or describe how to rollback changes. - Add the output of the migration(s) to the MR description. - Add tests for the migration in `spec/migrations` if necessary. See [Testing Rails migrations at GitLab](testing_guide/testing_migrations_guide.md) for more details. +- When [high-traffic](https://gitlab.com/gitlab-org/gitlab/-/blob/master/rubocop/migration_helpers.rb#L12) tables are involved in the migration, use the [`with_lock_retries`](migration_style_guide.md#retry-mechanism-when-acquiring-database-locks) helper method. Review the relevant [examples in our documentation](migration_style_guide.md#examples) for use cases and solutions. #### Preparation when adding or modifying queries diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 46ea91fcdf3..3e993243855 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -171,7 +171,7 @@ lock allow the database to process other statements. ### Examples -Removing a column: +**Removing a column:** ```ruby include Gitlab::Database::MigrationHelpers @@ -189,7 +189,7 @@ def down end ``` -Removing a foreign key: +**Removing a foreign key:** ```ruby include Gitlab::Database::MigrationHelpers @@ -207,7 +207,7 @@ def down end ``` -Changing default value for a column: +**Changing default value for a column:** ```ruby include Gitlab::Database::MigrationHelpers @@ -225,6 +225,88 @@ def down end ``` +**Creating a new table with a foreign key:** + +We can simply wrap the `create_table` method with `with_lock_retries`: + +```ruby +def up + with_lock_retries do + create_table :issues do |t| + t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade } + t.string :title, limit: 255 + end + end +end + +def down + drop_table :issues +end +``` + +**Creating a new table when we have two foreign keys:** + +For this, we'll need three migrations: + +1. Creating the table without foreign keys (with the indices). +1. Add foreign key to the first table. +1. Add foreign key to the second table. + +Creating the table: + +```ruby +def up + create_table :imports do |t| + t.bigint :project_id, null: false + t.bigint :user_id, null: false + t.string :jid, limit: 255 + end + + add_index :imports, :project_id + add_index :imports, :user_id +end + +def down + drop_table :imports +end +``` + +Adding foreign key to `projects`: + +```ruby +include Gitlab::Database::MigrationHelpers + +def up + with_lock_retries do + add_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade + end +end + +def down + with_lock_retries do + remove_foreign_key :imports, column: :project_id + end +end +``` + +Adding foreign key to `users`: + +```ruby +include Gitlab::Database::MigrationHelpers + +def up + with_lock_retries do + add_foreign_key :imports, :users, column: :user_id, on_delete: :cascade + end +end + +def down + with_lock_retries do + remove_foreign_key :imports, column: :user_id + end +end +``` + ### When to use the helper method The `with_lock_retries` helper method can be used when you normally use diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md index 6e16433dfee..ee4f759be3a 100644 --- a/doc/install/aws/index.md +++ b/doc/install/aws/index.md @@ -247,7 +247,17 @@ create the actual RDS instance. ![RDS Subnet Group](img/rds_subnet_group.png) -### Creating the database +### RDS Security Group + +We need a security group for our database that will allow inbound traffic from the instances we'll deploy in our `gitlab-loadbalancer-sec-group` later on: + +1. From the EC2 dashboard, select **Security Groups** from the left menu bar. +1. Click **Create security group**. +1. Give it a name (we'll use `gitlab-rds-sec-group`), a description, and select the `gitlab-vpc` from the **VPC** dropdown. +1. In the **Inbound rules** section, click **Add rule** and add a **PostgreSQL** rule, and set the "Custom" source as the `gitlab-loadbalancer-sec-group` we created earlier. The default PostgreSQL port is `5432`, which we'll also use when creating our database below. +1. When done, click **Create security group**. + +### Create the database Now, it's time to create the database: @@ -266,7 +276,7 @@ Now, it's time to create the database: 1. Select the VPC we created earlier (`gitlab-vpc`) from the **Virtual Private Cloud (VPC)** dropdown menu. 1. Expand the **Additional connectivity configuration** section and select the subnet group (`gitlab-rds-group`) we created earlier. 1. Set public accessibility to **No**. - 1. Under **VPC security group**, select **Create new** and enter a name. We'll use `gitlab-rds-sec-group`. + 1. Under **VPC security group**, select **Choose existing** and select the `gitlab-rds-sec-group` we create above from the dropdown. 1. Leave the database port as the default `5432`. 1. For **Database authentication**, select **Password authentication**. 1. Expand the **Additional configuration** section and complete the following: @@ -327,17 +337,6 @@ persistence and is used for certain types of the GitLab application. 1. Leave the rest of the settings to their default values or edit to your liking. 1. When done, click **Create**. -## RDS and Redis Security Group - -Let's navigate to our EC2 security groups and add a small change for our EC2 -instances to be able to connect to RDS. First, copy the security group name we -defined, namely `gitlab-security-group`, select the RDS security group and edit the -inbound rules. Choose the rule type to be PostgreSQL and paste the name under -source. - -Similar to the above, jump to the `gitlab-security-group` group -and add a custom TCP rule for port `6379` accessible within itself. - ## Setting up Bastion Hosts Since our GitLab instances will be in private subnets, we need a way to connect to these instances via SSH to make configuration changes, perform upgrades, etc. One way of doing this is via a [bastion host](https://en.wikipedia.org/wiki/Bastion_host), sometimes also referred to as a jump box. diff --git a/doc/install/installation.md b/doc/install/installation.md index f3574395a5c..f6eeec11539 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -969,6 +969,15 @@ If you want to switch back to Unicorn, follow these steps: 1. Edit the system `init.d` script to set the `USE_UNICORN=1` flag. If you have `/etc/default/gitlab`, then you should edit it instead. 1. Restart GitLab. +### Using Sidekiq instead of Sidekiq Cluster + +As of GitLab 12.10, Source installations are using `bin/sidekiq-cluster` for managing Sidekiq processes. +Using Sidekiq directly will still be supported until 14.0. So if you're experiencing issues, please: + +1. Edit the system `init.d` script to remove the `SIDEKIQ_WORKERS` flag. If you have `/etc/default/gitlab`, then you should edit it instead. +1. Restart GitLab. +1. [Create an issue](https://gitlab.com/gitlab-org/gitlab/issues/-/new) describing the problem. + ## Troubleshooting ### "You appear to have cloned an empty repository." diff --git a/doc/user/project/issues/design_management.md b/doc/user/project/issues/design_management.md index dd65c8f0929..40771c14ea4 100644 --- a/doc/user/project/issues/design_management.md +++ b/doc/user/project/issues/design_management.md @@ -75,6 +75,19 @@ you can drag and drop designs onto the dedicated dropzone to upload them. ![Drag and drop design uploads](img/design_drag_and_drop_uploads_v12_9.png) +[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/202634) +in GitLab 12.10, you can also copy images from your file system and +paste them directly on GitLab's Design page as a new design. + +On macOS you can also take a screenshot and immediately copy it to +the clipboard by simultaneously clicking Control + Command + Shift + 3, and then paste it as a design. + +Copy-and-pasting has some limitations: + +- You can paste only one image at a time. When copy/pasting multiple files, only the first one will be uploaded. +- All images will be converted to `png` format under the hood, so when you want to copy/paste `gif` file, it will result in broken animation. +- Copy/pasting designs is not supported on Internet Explorer. + Designs with the same filename as an existing uploaded design will create a new version of the design, and will replace the previous version. [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34353) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9, dropping a design on an existing uploaded design will also create a new version, provided the filenames are the same. diff --git a/lib/gitlab/sidekiq_middleware/metrics.rb b/lib/gitlab/sidekiq_middleware/metrics.rb index 693e35f2500..7ae8995c46d 100644 --- a/lib/gitlab/sidekiq_middleware/metrics.rb +++ b/lib/gitlab/sidekiq_middleware/metrics.rb @@ -9,7 +9,13 @@ module Gitlab private def create_labels(worker_class, queue) - labels = { queue: queue.to_s, urgency: "", external_dependencies: FALSE_LABEL, feature_category: "", boundary: "" } + labels = { queue: queue.to_s, + worker: worker_class.to_s, + urgency: "", + external_dependencies: FALSE_LABEL, + feature_category: "", + boundary: "" } + return labels unless worker_class && worker_class.include?(WorkerAttributes) labels[:urgency] = worker_class.get_urgency.to_s diff --git a/lib/support/init.d/gitlab b/lib/support/init.d/gitlab index 982c1dc8866..5e8e2ab9c25 100755 --- a/lib/support/init.d/gitlab +++ b/lib/support/init.d/gitlab @@ -27,6 +27,7 @@ ### Environment variables RAILS_ENV="production" USE_UNICORN="" +SIDEKIQ_WORKERS=1 # Script variable names should be lower-case not to conflict with # internal /bin/sh variables such as PATH, EDITOR or SHELL. @@ -36,7 +37,6 @@ pid_path="$app_root/tmp/pids" socket_path="$app_root/tmp/sockets" rails_socket="$socket_path/gitlab.socket" web_server_pid_path="$pid_path/unicorn.pid" -sidekiq_pid_path="$pid_path/sidekiq.pid" mail_room_enabled=false mail_room_pid_path="$pid_path/mail_room.pid" gitlab_workhorse_dir=$(cd $app_root/../gitlab-workhorse 2> /dev/null && pwd) @@ -74,6 +74,11 @@ else use_web_server="unicorn" fi +if [ -z "$SIDEKIQ_WORKERS" ]; then + sidekiq_pid_path="$pid_path/sidekiq.pid" +else + sidekiq_pid_path="$pid_path/sidekiq-cluster.pid" +fi ### Init Script functions @@ -295,7 +300,7 @@ start_gitlab() { if [ "$sidekiq_status" = "0" ]; then echo "The Sidekiq job dispatcher is already running with pid $spid, not restarting" else - RAILS_ENV=$RAILS_ENV bin/background_jobs start & + RAILS_ENV=$RAILS_ENV SIDEKIQ_WORKERS=$SIDEKIQ_WORKERS bin/background_jobs start & fi if [ "$gitlab_workhorse_status" = "0" ]; then @@ -354,7 +359,7 @@ stop_gitlab() { fi if [ "$sidekiq_status" = "0" ]; then echo "Shutting down GitLab Sidekiq" - RAILS_ENV=$RAILS_ENV bin/background_jobs stop + RAILS_ENV=$RAILS_ENV SIDEKIQ_WORKERS=$SIDEKIQ_WORKERS bin/background_jobs stop fi if [ "$gitlab_workhorse_status" = "0" ]; then echo "Shutting down GitLab Workhorse" @@ -458,7 +463,7 @@ reload_gitlab(){ echo "Done." echo "Restarting GitLab Sidekiq since it isn't capable of reloading its config..." - RAILS_ENV=$RAILS_ENV bin/background_jobs restart + RAILS_ENV=$RAILS_ENV SIDEKIQ_WORKERS=$SIDEKIQ_WORKERS bin/background_jobs restart if [ "$mail_room_enabled" != true ]; then echo "Restarting GitLab MailRoom since it isn't capable of reloading its config..." diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb index 0613148172f..74d3544ce92 100644 --- a/spec/features/projects/settings/registry_settings_spec.rb +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -26,7 +26,6 @@ describe 'Project > Settings > CI/CD > Container registry tag expiration policy' it 'saves expiration policy submit the form' do within '#js-registry-policies' do within '.card-body' do - find('.gl-toggle-wrapper button:not(.is-disabled)').click select('7 days until tags are automatically removed', from: 'Expiration interval:') select('Every day', from: 'Expiration schedule:') select('50 tags per image name', from: 'Number of tags to retain:') diff --git a/spec/frontend/__mocks__/sortablejs/index.js b/spec/frontend/__mocks__/sortablejs/index.js index a1166d21561..5039af54542 100644 --- a/spec/frontend/__mocks__/sortablejs/index.js +++ b/spec/frontend/__mocks__/sortablejs/index.js @@ -1,4 +1,4 @@ -import Sortablejs from 'sortablejs'; +const Sortablejs = jest.genMockFromModule('sortablejs'); export default Sortablejs; export const Sortable = Sortablejs; diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js new file mode 100644 index 00000000000..7cf6ec913b4 --- /dev/null +++ b/spec/frontend/boards/components/board_column_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import Board from '~/boards/components/board_column.vue'; +import List from '~/boards/models/list'; +import { ListType } from '~/boards/constants'; +import axios from '~/lib/utils/axios_utils'; + +import { TEST_HOST } from 'helpers/test_constants'; +import { listObj } from 'jest/boards/mock_data'; + +describe('Board Column Component', () => { + let wrapper; + let axiosMock; + + beforeEach(() => { + window.gon = {}; + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); + }); + + afterEach(() => { + axiosMock.restore(); + + wrapper.destroy(); + + localStorage.clear(); + }); + + const createComponent = ({ + listType = ListType.backlog, + collapsed = false, + withLocalStorage = true, + } = {}) => { + const boardId = '1'; + + const listMock = { + ...listObj, + list_type: listType, + collapsed, + }; + + if (listType === ListType.assignee) { + delete listMock.label; + listMock.user = {}; + } + + // Making List reactive + const list = Vue.observable(new List(listMock)); + + if (withLocalStorage) { + localStorage.setItem( + `boards.${boardId}.${list.type}.${list.id}.expanded`, + (!collapsed).toString(), + ); + } + + wrapper = shallowMount(Board, { + propsData: { + boardId, + disabled: false, + issueLinkBase: '/', + rootPath: '/', + list, + }, + }); + }; + + const isExpandable = () => wrapper.classes('is-expandable'); + const isCollapsed = () => wrapper.classes('is-collapsed'); + + const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); + + describe('Add issue button', () => { + const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; + const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; + + it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(false); + }); + + it.each(hasAddButton)('does render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(true); + }); + + it('has a test for each list type', () => { + Object.values(ListType).forEach(value => { + expect([...hasAddButton, ...hasNoAddButton]).toContain(value); + }); + }); + + it('does render when logged out', () => { + createComponent(); + + expect(findAddIssueButton().exists()).toBe(true); + }); + }); + + describe('Given different list types', () => { + it('is expandable when List Type is `backlog`', () => { + createComponent({ listType: ListType.backlog }); + + expect(isExpandable()).toBe(true); + }); + }); + + describe('expanding / collapsing the column', () => { + it('does not collapse when clicking the header', () => { + createComponent(); + expect(isCollapsed()).toBe(false); + wrapper.find('.board-header').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); + }); + + it('collapses expanded Column when clicking the collapse icon', () => { + createComponent(); + expect(wrapper.vm.list.isExpanded).toBe(true); + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(true); + }); + }); + + it('expands collapsed Column when clicking the expand icon', () => { + createComponent({ collapsed: true }); + expect(isCollapsed()).toBe(true); + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); + }); + + it("when logged in it calls list update and doesn't set localStorage", () => { + jest.spyOn(List.prototype, 'update'); + window.gon.current_user_id = 1; + + createComponent({ withLocalStorage: false }); + + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + }); + }); + + it("when logged out it doesn't call list update and sets localStorage", () => { + jest.spyOn(List.prototype, 'update'); + + createComponent(); + + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(0); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe( + String(wrapper.vm.list.isExpanded), + ); + }); + }); + }); +}); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js index c0dd5afe498..b30281f8df5 100644 --- a/spec/frontend/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -56,7 +56,7 @@ describe('List model', () => { label: { id: 1, title: 'test', - color: 'red', + color: '#ff0000', text_color: 'white', }, }); @@ -64,8 +64,7 @@ describe('List model', () => { expect(list.id).toBe(listObj.id); expect(list.type).toBe('label'); expect(list.position).toBe(0); - expect(list.label.color).toBe('red'); - expect(list.label.textColor).toBe('white'); + expect(list.label).toEqual(listObj.label); }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index fa4154676a2..97d49de6f2e 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -15,7 +15,7 @@ export const listObj = { label: { id: 5000, title: 'Test', - color: 'red', + color: '#ff0000', description: 'testing;', textColor: 'white', }, @@ -30,7 +30,7 @@ export const listObjDuplicate = { label: { id: listObj.label.id, title: 'Test', - color: 'red', + color: '#ff0000', description: 'testing;', }, }; diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js index 1255d6fc14f..1dff5d4f925 100644 --- a/spec/frontend/lib/utils/file_upload_spec.js +++ b/spec/frontend/lib/utils/file_upload_spec.js @@ -1,4 +1,4 @@ -import fileUpload from '~/lib/utils/file_upload'; +import fileUpload, { getFilename } from '~/lib/utils/file_upload'; describe('File upload', () => { beforeEach(() => { @@ -62,3 +62,15 @@ describe('File upload', () => { expect(input.click).not.toHaveBeenCalled(); }); }); + +describe('getFilename', () => { + it('returns first value correctly', () => { + const event = { + clipboardData: { + getData: () => 'test.png\rtest.txt', + }, + }; + + expect(getFilename(event)).toBe('test.png'); + }); +}); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index 4c76f9c50fb..9162bee2078 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import createStore from '~/notes/stores'; import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; -import { discussionMock } from '../../../javascripts/notes/mock_data'; +import { discussionMock } from '../mock_data'; import mockDiffFile from '../../diffs/mock_data/diff_discussions'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js new file mode 100644 index 00000000000..d6d42e1988d --- /dev/null +++ b/spec/frontend/notes/components/diff_with_note_spec.js @@ -0,0 +1,86 @@ +import { mount } from '@vue/test-utils'; +import DiffWithNote from '~/notes/components/diff_with_note.vue'; +import { createStore } from '~/mr_notes/stores'; + +const discussionFixture = 'merge_requests/diff_discussion.json'; +const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; + +describe('diff_with_note', () => { + let store; + let wrapper; + + const selectors = { + get diffTable() { + return wrapper.find('.diff-content table'); + }, + get diffRows() { + return wrapper.findAll('.diff-content .line_holder'); + }, + get noteRow() { + return wrapper.find('.diff-content .notes_holder'); + }, + }; + + beforeEach(() => { + store = createStore(); + store.replaceState({ + ...store.state, + notes: { + noteableData: { + current_user: {}, + }, + }, + }); + }); + + describe('text diff', () => { + beforeEach(() => { + const diffDiscussion = getJSONFixture(discussionFixture)[0]; + + wrapper = mount(DiffWithNote, { + propsData: { + discussion: diffDiscussion, + }, + store, + }); + }); + + it('removes trailing "+" char', () => { + const richText = wrapper.vm.$el + .querySelectorAll('.line_holder')[4] + .querySelector('.line_content').textContent[0]; + + expect(richText).not.toEqual('+'); + }); + + it('removes trailing "-" char', () => { + const richText = wrapper.vm.$el.querySelector('#LC13').parentNode.textContent[0]; + + expect(richText).not.toEqual('-'); + }); + + it('shows text diff', () => { + expect(wrapper.classes('text-file')).toBe(true); + expect(selectors.diffTable.exists()).toBe(true); + }); + + it('shows diff lines', () => { + expect(selectors.diffRows.length).toBe(12); + }); + + it('shows notes row', () => { + expect(selectors.noteRow.exists()).toBe(true); + }); + }); + + describe('image diff', () => { + beforeEach(() => { + const imageDiscussion = getJSONFixture(imageDiscussionFixture)[0]; + wrapper = mount(DiffWithNote, { propsData: { discussion: imageDiscussion }, store }); + }); + + it('shows image diff', () => { + expect(selectors.diffTable.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js new file mode 100644 index 00000000000..b8d2d721443 --- /dev/null +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -0,0 +1,219 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; + +import { createLocalVue, mount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import axios from '~/lib/utils/axios_utils'; +import notesModule from '~/notes/stores/modules'; +import DiscussionFilter from '~/notes/components/discussion_filter.vue'; +import { DISCUSSION_FILTERS_DEFAULT_VALUE, DISCUSSION_FILTER_TYPES } from '~/notes/constants'; + +import { discussionFiltersMock, discussionMock } from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +const DISCUSSION_PATH = `${TEST_HOST}/example`; + +describe('DiscussionFilter component', () => { + let wrapper; + let store; + let eventHub; + let mock; + + const filterDiscussion = jest.fn(); + + const mountComponent = () => { + const discussions = [ + { + ...discussionMock, + id: discussionMock.id, + notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], + }, + ]; + + const defaultStore = { ...notesModule() }; + + store = new Vuex.Store({ + ...defaultStore, + actions: { + ...defaultStore.actions, + filterDiscussion, + }, + }); + + store.state.notesData.discussionsPath = DISCUSSION_PATH; + + store.state.discussions = discussions; + + return mount(DiscussionFilter, { + store, + propsData: { + filters: discussionFiltersMock, + selectedValue: DISCUSSION_FILTERS_DEFAULT_VALUE, + }, + localVue, + }); + }; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + + // We are mocking the discussions retrieval, + // as it doesn't matter for our tests here + mock.onGet(DISCUSSION_PATH).reply(200, ''); + window.mrTabs = undefined; + wrapper = mountComponent(); + }); + + afterEach(() => { + wrapper.vm.$destroy(); + mock.restore(); + }); + + it('renders the all filters', () => { + expect(wrapper.findAll('.dropdown-menu li').length).toBe(discussionFiltersMock.length); + }); + + it('renders the default selected item', () => { + expect( + wrapper + .find('#discussion-filter-dropdown') + .text() + .trim(), + ).toBe(discussionFiltersMock[0].title); + }); + + it('updates to the selected item', () => { + const filterItem = wrapper.find( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, + ); + + filterItem.trigger('click'); + + expect(wrapper.vm.currentFilter.title).toBe(filterItem.text().trim()); + }); + + it('only updates when selected filter changes', () => { + wrapper + .find(`.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`) + .trigger('click'); + + expect(filterDiscussion).not.toHaveBeenCalled(); + }); + + it('disables commenting when "Show history only" filter is applied', () => { + const filterItem = wrapper.find( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, + ); + filterItem.trigger('click'); + + expect(wrapper.vm.$store.state.commentsDisabled).toBe(true); + }); + + it('enables commenting when "Show history only" filter is not applied', () => { + const filterItem = wrapper.find( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`, + ); + filterItem.trigger('click'); + + expect(wrapper.vm.$store.state.commentsDisabled).toBe(false); + }); + + it('renders a dropdown divider for the default filter', () => { + const defaultFilter = wrapper.findAll( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] > *`, + ); + + expect(defaultFilter.at(defaultFilter.length - 1).classes('dropdown-divider')).toBe(true); + }); + + describe('Merge request tabs', () => { + eventHub = new Vue(); + + beforeEach(() => { + window.mrTabs = { + eventHub, + currentTab: 'show', + }; + + wrapper = mountComponent(); + }); + + afterEach(() => { + window.mrTabs = undefined; + }); + + it('only renders when discussion tab is active', done => { + eventHub.$emit('MergeRequestTabChange', 'commit'); + + wrapper.vm.$nextTick(() => { + expect(wrapper.isEmpty()).toBe(true); + done(); + }); + }); + }); + + describe('URL with Links to notes', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('updates the filter when the URL links to a note', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + wrapper.vm.currentValue = discussionFiltersMock[2].value; + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('does not update the filter when the current filter is "Show all activity"', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('only updates filter when the URL links to a note', done => { + window.location.hash = `testing123`; + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('fetches discussions when there is a hash', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + wrapper.vm.currentValue = discussionFiltersMock[2].value; + jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.selectFilter).toHaveBeenCalled(); + done(); + }); + }); + + it('does not fetch discussions when there is no hash', done => { + window.location.hash = ''; + jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.selectFilter).not.toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js new file mode 100644 index 00000000000..4348445f7ca --- /dev/null +++ b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js @@ -0,0 +1,30 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { TEST_HOST } from 'spec/test_constants'; +import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; + +const localVue = createLocalVue(); + +describe('ResolveWithIssueButton', () => { + let wrapper; + const url = `${TEST_HOST}/hello-world/`; + + beforeEach(() => { + wrapper = shallowMount(ResolveWithIssueButton, { + localVue, + propsData: { + url, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('it should have a link with the provided link property as href', () => { + const button = wrapper.find(GlButton); + + expect(button.attributes().href).toBe(url); + }); +}); diff --git a/spec/frontend/notes/components/note_actions/reply_button_spec.js b/spec/frontend/notes/components/note_actions/reply_button_spec.js new file mode 100644 index 00000000000..720ab10b270 --- /dev/null +++ b/spec/frontend/notes/components/note_actions/reply_button_spec.js @@ -0,0 +1,29 @@ +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ReplyButton', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(localVue.extend(ReplyButton), { + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits startReplying on click', () => { + const button = wrapper.find({ ref: 'button' }); + + button.trigger('click'); + + expect(wrapper.emitted().startReplying).toBeTruthy(); + expect(wrapper.emitted().startReplying.length).toBe(1); + }); +}); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js new file mode 100644 index 00000000000..5d13f587ca7 --- /dev/null +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -0,0 +1,160 @@ +import Vue from 'vue'; +import { shallowMount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { TEST_HOST } from 'spec/test_constants'; +import createStore from '~/notes/stores'; +import noteActions from '~/notes/components/note_actions.vue'; +import { userDataMock } from '../mock_data'; + +describe('noteActions', () => { + let wrapper; + let store; + let props; + + const shallowMountNoteActions = propsData => { + const localVue = createLocalVue(); + return shallowMount(localVue.extend(noteActions), { + store, + propsData, + localVue, + }); + }; + + beforeEach(() => { + store = createStore(); + props = { + accessLevel: 'Maintainer', + authorId: 26, + canDelete: true, + canEdit: true, + canAwardEmoji: true, + canReportAsAbuse: true, + noteId: '539', + noteUrl: `${TEST_HOST}/group/project/-/merge_requests/1#note_1`, + reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`, + showReply: false, + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('user is logged in', () => { + beforeEach(() => { + store.dispatch('setUserData', userDataMock); + + wrapper = shallowMountNoteActions(props); + }); + + it('should render access level badge', () => { + expect( + wrapper + .find('.note-role') + .text() + .trim(), + ).toEqual(props.accessLevel); + }); + + it('should render emoji link', () => { + expect(wrapper.find('.js-add-award').exists()).toBe(true); + expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right'); + }); + + describe('actions dropdown', () => { + it('should be possible to edit the comment', () => { + expect(wrapper.find('.js-note-edit').exists()).toBe(true); + }); + + it('should be possible to report abuse to admin', () => { + expect(wrapper.find(`a[href="${props.reportAbusePath}"]`).exists()).toBe(true); + }); + + it('should be possible to copy link to a note', () => { + expect(wrapper.find('.js-btn-copy-note-link').exists()).toBe(true); + }); + + it('should not show copy link action when `noteUrl` prop is empty', done => { + wrapper.setProps({ + ...props, + noteUrl: '', + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-btn-copy-note-link').exists()).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + + it('should be possible to delete comment', () => { + expect(wrapper.find('.js-note-delete').exists()).toBe(true); + }); + + it('closes tooltip when dropdown opens', done => { + wrapper.find('.more-actions-toggle').trigger('click'); + + const rootWrapper = createWrapper(wrapper.vm.$root); + Vue.nextTick() + .then(() => { + const emitted = Object.keys(rootWrapper.emitted()); + + expect(emitted).toEqual(['bv::hide::tooltip']); + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + store.dispatch('setUserData', {}); + wrapper = shallowMountNoteActions({ + ...props, + canDelete: false, + canEdit: false, + canAwardEmoji: false, + canReportAsAbuse: false, + }); + }); + + it('should not render emoji link', () => { + expect(wrapper.find('.js-add-award').exists()).toBe(false); + }); + + it('should not render actions dropdown', () => { + expect(wrapper.find('.more-actions').exists()).toBe(false); + }); + }); + + describe('for showReply = true', () => { + beforeEach(() => { + wrapper = shallowMountNoteActions({ + ...props, + showReply: true, + }); + }); + + it('shows a reply button', () => { + const replyButton = wrapper.find({ ref: 'replyButton' }); + + expect(replyButton.exists()).toBe(true); + }); + }); + + describe('for showReply = false', () => { + beforeEach(() => { + wrapper = shallowMountNoteActions({ + ...props, + showReply: false, + }); + }); + + it('does not show a reply button', () => { + const replyButton = wrapper.find({ ref: 'replyButton' }); + + expect(replyButton.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js new file mode 100644 index 00000000000..822b1f9efce --- /dev/null +++ b/spec/frontend/notes/components/note_awards_list_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import createStore from '~/notes/stores'; +import awardsNote from '~/notes/components/note_awards_list.vue'; +import { noteableDataMock, notesDataMock } from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; + +describe('note_awards_list component', () => { + let store; + let vm; + let awardsMock; + let mock; + + const toggleAwardPath = `${TEST_HOST}/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji`; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + + mock.onPost(toggleAwardPath).reply(200, ''); + + const Component = Vue.extend(awardsNote); + + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + awardsMock = [ + { + name: 'flag_tz', + user: { id: 1, name: 'Administrator', username: 'root' }, + }, + { + name: 'cartwheel_tone3', + user: { id: 12, name: 'Bobbie Stehr', username: 'erin' }, + }, + ]; + + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: true, + toggleAwardPath, + }, + }).$mount(); + }); + + afterEach(() => { + mock.restore(); + vm.$destroy(); + }); + + it('should render awarded emojis', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined(); + expect( + vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]'), + ).toBeDefined(); + }); + + it('should be possible to remove awarded emoji', () => { + jest.spyOn(vm, 'handleAward'); + jest.spyOn(vm, 'toggleAwardRequest'); + vm.$el.querySelector('.js-awards-block button').click(); + + expect(vm.handleAward).toHaveBeenCalledWith('flag_tz'); + expect(vm.toggleAwardRequest).toHaveBeenCalled(); + }); + + it('should be possible to add new emoji', () => { + expect(vm.$el.querySelector('.js-add-award')).toBeDefined(); + }); + + describe('when the user name contains special HTML characters', () => { + const createAwardEmoji = (_, index) => ({ + name: 'art', + user: { id: index, name: `&<>"\`'-${index}`, username: `user-${index}` }, + }); + + const mountComponent = () => { + const Component = Vue.extend(awardsNote); + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 0, + noteId: '545', + canAwardEmoji: true, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }, + }).$mount(); + }; + + const findTooltip = () => + vm.$el.querySelector('[data-original-title]').getAttribute('data-original-title'); + + it('should only escape & and " characters', () => { + awardsMock = [...new Array(1)].map(createAwardEmoji); + mountComponent(); + const escapedName = awardsMock[0].user.name.replace(/&/g, '&').replace(/"/g, '"'); + + expect(vm.$el.querySelector('[data-original-title]').outerHTML).toContain(escapedName); + }); + + it('should not escape special HTML characters twice when only 1 person awarded', () => { + awardsMock = [...new Array(1)].map(createAwardEmoji); + mountComponent(); + + awardsMock.forEach(award => { + expect(findTooltip()).toContain(award.user.name); + }); + }); + + it('should not escape special HTML characters twice when 2 people awarded', () => { + awardsMock = [...new Array(2)].map(createAwardEmoji); + mountComponent(); + + awardsMock.forEach(award => { + expect(findTooltip()).toContain(award.user.name); + }); + }); + + it('should not escape special HTML characters twice when more than 10 people awarded', () => { + awardsMock = [...new Array(11)].map(createAwardEmoji); + mountComponent(); + + // Testing only the first 10 awards since 11 onward will not be displayed. + awardsMock.slice(0, 10).forEach(award => { + expect(findTooltip()).toContain(award.user.name); + }); + }); + }); + + describe('when the user cannot award emoji', () => { + beforeEach(() => { + const Component = Vue.extend(awardsNote); + + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: false, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }, + }).$mount(); + }); + + it('should not be possible to remove awarded emoji', () => { + jest.spyOn(vm, 'toggleAwardRequest'); + + vm.$el.querySelector('.js-awards-block button').click(); + + expect(vm.toggleAwardRequest).not.toHaveBeenCalled(); + }); + + it('should not be possible to add new emoji', () => { + expect(vm.$el.querySelector('.js-add-award')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js new file mode 100644 index 00000000000..efad0785afe --- /dev/null +++ b/spec/frontend/notes/components/note_body_spec.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import createStore from '~/notes/stores'; +import noteBody from '~/notes/components/note_body.vue'; +import { noteableDataMock, notesDataMock, note } from '../mock_data'; + +describe('issue_note_body component', () => { + let store; + let vm; + + beforeEach(() => { + const Component = Vue.extend(noteBody); + + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + propsData: { + note, + canEdit: true, + canAwardEmoji: true, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render the note', () => { + expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); + }); + + it('should render awards list', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull(); + expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull(); + }); + + describe('isEditing', () => { + beforeEach(done => { + vm.isEditing = true; + Vue.nextTick(done); + }); + + it('renders edit form', () => { + expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull(); + }); + + it('adds autosave', () => { + const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; + + expect(vm.autosave).toExist(); + expect(vm.autosave.key).toEqual(autosaveKey); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js new file mode 100644 index 00000000000..bccac03126c --- /dev/null +++ b/spec/frontend/notes/components/note_form_spec.js @@ -0,0 +1,248 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/notes/stores'; +import NoteForm from '~/notes/components/note_form.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { noteableDataMock, notesDataMock } from '../mock_data'; + +import { getDraft, updateDraft } from '~/lib/utils/autosave'; + +jest.mock('~/lib/utils/autosave'); + +describe('issue_note_form component', () => { + const dummyAutosaveKey = 'some-autosave-key'; + const dummyDraft = 'dummy draft content'; + + let store; + let wrapper; + let props; + + const createComponentWrapper = () => { + const localVue = createLocalVue(); + return shallowMount(localVue.extend(NoteForm), { + store, + propsData: props, + // see https://gitlab.com/gitlab-org/gitlab-foss/issues/56317 for the following + localVue, + }); + }; + + beforeEach(() => { + getDraft.mockImplementation(key => { + if (key === dummyAutosaveKey) { + return dummyDraft; + } + + return null; + }); + + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + props = { + isEditing: false, + noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', + noteId: '545', + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('noteHash', () => { + beforeEach(() => { + wrapper = createComponentWrapper(); + }); + + it('returns note hash string based on `noteId`', () => { + expect(wrapper.vm.noteHash).toBe(`#note_${props.noteId}`); + }); + + it('return note hash as `#` when `noteId` is empty', () => { + wrapper.setProps({ + ...props, + noteId: '', + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.noteHash).toBe('#'); + }); + }); + }); + + describe('conflicts editing', () => { + beforeEach(() => { + wrapper = createComponentWrapper(); + }); + + it('should show conflict message if note changes outside the component', () => { + wrapper.setProps({ + ...props, + isEditing: true, + noteBody: 'Foo', + }); + + const message = + 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; + + return wrapper.vm.$nextTick().then(() => { + const conflictWarning = wrapper.find('.js-conflict-edit-warning'); + + expect(conflictWarning.exists()).toBe(true); + expect( + conflictWarning + .text() + .replace(/\s+/g, ' ') + .trim(), + ).toBe(message); + }); + }); + }); + + describe('form', () => { + beforeEach(() => { + wrapper = createComponentWrapper(); + }); + + it('should render text area with placeholder', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.attributes('placeholder')).toEqual( + 'Write a comment or drag your files hereā€¦', + ); + }); + + it('should link to markdown docs', () => { + const { markdownDocsPath } = notesDataMock; + const markdownField = wrapper.find(MarkdownField); + const markdownFieldProps = markdownField.props(); + + expect(markdownFieldProps.markdownDocsPath).toBe(markdownDocsPath); + }); + + describe('keyboard events', () => { + let textarea; + + beforeEach(() => { + textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + }); + + describe('up', () => { + it('should ender edit mode', () => { + // TODO: do not spy on vm + jest.spyOn(wrapper.vm, 'editMyLastNote'); + + textarea.trigger('keydown.up'); + + expect(wrapper.vm.editMyLastNote).toHaveBeenCalled(); + }); + }); + + describe('enter', () => { + it('should save note when cmd+enter is pressed', () => { + textarea.trigger('keydown.enter', { metaKey: true }); + + const { handleFormUpdate } = wrapper.emitted(); + + expect(handleFormUpdate.length).toBe(1); + }); + + it('should save note when ctrl+enter is pressed', () => { + textarea.trigger('keydown.enter', { ctrlKey: true }); + + const { handleFormUpdate } = wrapper.emitted(); + + expect(handleFormUpdate.length).toBe(1); + }); + }); + }); + + describe('actions', () => { + it('should be possible to cancel', () => { + // TODO: do not spy on vm + jest.spyOn(wrapper.vm, 'cancelHandler'); + wrapper.setProps({ + ...props, + isEditing: true, + }); + + return wrapper.vm.$nextTick().then(() => { + const cancelButton = wrapper.find('.note-edit-cancel'); + cancelButton.trigger('click'); + + expect(wrapper.vm.cancelHandler).toHaveBeenCalled(); + }); + }); + + it('should be possible to update the note', () => { + wrapper.setProps({ + ...props, + isEditing: true, + }); + + return wrapper.vm.$nextTick().then(() => { + const textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + const saveButton = wrapper.find('.js-vue-issue-save'); + saveButton.trigger('click'); + + expect(wrapper.vm.isSubmitting).toBe(true); + }); + }); + }); + }); + + describe('with autosaveKey', () => { + describe('with draft', () => { + beforeEach(() => { + Object.assign(props, { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }); + wrapper = createComponentWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('displays the draft in textarea', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.element.value).toBe(dummyDraft); + }); + }); + + describe('without draft', () => { + beforeEach(() => { + Object.assign(props, { + noteBody: '', + autosaveKey: 'some key without draft', + }); + wrapper = createComponentWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('leaves the textarea empty', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.element.value).toBe(''); + }); + }); + + it('updates the draft if textarea content changes', () => { + Object.assign(props, { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }); + wrapper = createComponentWrapper(); + const textarea = wrapper.find('textarea'); + const dummyContent = 'some new content'; + + textarea.setValue(dummyContent); + + expect(updateDraft).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_signed_out_widget_spec.js b/spec/frontend/notes/components/note_signed_out_widget_spec.js new file mode 100644 index 00000000000..e217a2caa73 --- /dev/null +++ b/spec/frontend/notes/components/note_signed_out_widget_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import noteSignedOut from '~/notes/components/note_signed_out_widget.vue'; +import createStore from '~/notes/stores'; +import { notesDataMock } from '../mock_data'; + +describe('note_signed_out_widget component', () => { + let store; + let vm; + + beforeEach(() => { + const Component = Vue.extend(noteSignedOut); + store = createStore(); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render sign in link provided in the store', () => { + expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual( + 'sign in', + ); + }); + + it('should render register link provided in the store', () => { + expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual( + 'register', + ); + }); + + it('should render information text', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual( + 'Please register or sign in to reply', + ); + }); +}); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js new file mode 100644 index 00000000000..b91f599f158 --- /dev/null +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -0,0 +1,187 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/notes/stores'; +import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; +import NoteForm from '~/notes/components/note_form.vue'; +import '~/behaviors/markdown/render_gfm'; +import { + noteableDataMock, + discussionMock, + notesDataMock, + loggedOutnoteableData, + userDataMock, +} from '../mock_data'; +import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { trimText } from 'helpers/text_helper'; + +const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; + +const localVue = createLocalVue(); + +describe('noteable_discussion component', () => { + let store; + let wrapper; + let originalGon; + + preloadFixtures(discussionWithTwoUnresolvedNotes); + + beforeEach(() => { + window.mrTabs = {}; + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should not render thread header for non diff threads', () => { + expect(wrapper.find('.discussion-header').exists()).toBe(false); + }); + + it('should render thread header', () => { + const discussion = { ...discussionMock }; + discussion.diff_file = mockDiffFile; + discussion.diff_discussion = true; + discussion.expanded = false; + + wrapper.setProps({ discussion }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.discussion-header').exists()).toBe(true); + }); + }); + + describe('actions', () => { + it('should toggle reply form', () => { + const replyPlaceholder = wrapper.find(ReplyPlaceholder); + + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.isReplying).toEqual(false); + + replyPlaceholder.vm.$emit('onClick'); + }) + .then(() => wrapper.vm.$nextTick()) + .then(() => { + expect(wrapper.vm.isReplying).toEqual(true); + + const noteForm = wrapper.find(NoteForm); + + expect(noteForm.exists()).toBe(true); + + const noteFormProps = noteForm.props(); + + expect(noteFormProps.discussion).toBe(discussionMock); + expect(noteFormProps.isEditing).toBe(false); + expect(noteFormProps.line).toBe(null); + expect(noteFormProps.saveButtonTitle).toBe('Comment'); + expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`); + }); + }); + + it('does not render jump to thread button', () => { + expect(wrapper.find('*[data-original-title="Jump to next unresolved thread"]').exists()).toBe( + false, + ); + }); + }); + + describe('for resolved thread', () => { + beforeEach(() => { + const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; + wrapper.setProps({ discussion }); + }); + + it('does not display a button to resolve with issue', () => { + const button = wrapper.find(ResolveWithIssueButton); + + expect(button.exists()).toBe(false); + }); + }); + + describe('for unresolved thread', () => { + beforeEach(() => { + const discussion = { + ...getJSONFixture(discussionWithTwoUnresolvedNotes)[0], + expanded: true, + }; + discussion.notes = discussion.notes.map(note => ({ + ...note, + resolved: false, + current_user: { + ...note.current_user, + can_resolve: true, + }, + })); + + wrapper.setProps({ discussion }); + + return wrapper.vm.$nextTick(); + }); + + it('displays a button to resolve with issue', () => { + const button = wrapper.find(ResolveWithIssueButton); + + expect(button.exists()).toBe(true); + }); + }); + + describe('signout widget', () => { + beforeEach(() => { + originalGon = Object.assign({}, window.gon); + window.gon = window.gon || {}; + }); + + afterEach(() => { + wrapper.destroy(); + window.gon = originalGon; + }); + + describe('user is logged in', () => { + beforeEach(() => { + window.gon.current_user_id = userDataMock.id; + store.dispatch('setUserData', userDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + it('should not render signed out widget', () => { + expect(Boolean(wrapper.vm.isLoggedIn)).toBe(true); + expect(trimText(wrapper.text())).not.toContain('Please register or sign in to reply'); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + window.gon.current_user_id = null; + store.dispatch('setNoteableData', loggedOutnoteableData); + store.dispatch('setNotesData', notesDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + it('should render signed out widget', () => { + expect(Boolean(wrapper.vm.isLoggedIn)).toBe(false); + expect(trimText(wrapper.text())).toContain('Please register or sign in to reply'); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js new file mode 100644 index 00000000000..0d67b1d87a9 --- /dev/null +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -0,0 +1,137 @@ +import { escape } from 'lodash'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/notes/stores'; +import issueNote from '~/notes/components/noteable_note.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import NoteActions from '~/notes/components/note_actions.vue'; +import NoteBody from '~/notes/components/note_body.vue'; +import { noteableDataMock, notesDataMock, note } from '../mock_data'; + +describe('issue_note', () => { + let store; + let wrapper; + + beforeEach(() => { + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + const localVue = createLocalVue(); + wrapper = shallowMount(localVue.extend(issueNote), { + store, + propsData: { + note, + }, + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render user information', () => { + const { author } = note; + const avatar = wrapper.find(UserAvatarLink); + const avatarProps = avatar.props(); + + expect(avatarProps.linkHref).toBe(author.path); + expect(avatarProps.imgSrc).toBe(author.avatar_url); + expect(avatarProps.imgAlt).toBe(author.name); + expect(avatarProps.imgSize).toBe(40); + }); + + it('should render note header content', () => { + const noteHeader = wrapper.find(NoteHeader); + const noteHeaderProps = noteHeader.props(); + + expect(noteHeaderProps.author).toEqual(note.author); + expect(noteHeaderProps.createdAt).toEqual(note.created_at); + expect(noteHeaderProps.noteId).toEqual(note.id); + }); + + it('should render note actions', () => { + const { author } = note; + const noteActions = wrapper.find(NoteActions); + const noteActionsProps = noteActions.props(); + + expect(noteActionsProps.authorId).toBe(author.id); + expect(noteActionsProps.noteId).toBe(note.id); + expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); + expect(noteActionsProps.accessLevel).toBe(note.human_access); + expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); + expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); + expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); + expect(noteActionsProps.canReportAsAbuse).toBe(true); + expect(noteActionsProps.canResolve).toBe(false); + expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); + expect(noteActionsProps.resolvable).toBe(false); + expect(noteActionsProps.isResolved).toBe(false); + expect(noteActionsProps.isResolving).toBe(false); + expect(noteActionsProps.resolvedBy).toEqual({}); + }); + + it('should render issue body', () => { + const noteBody = wrapper.find(NoteBody); + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note).toEqual(note); + expect(noteBodyProps.line).toBe(null); + expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); + expect(noteBodyProps.isEditing).toBe(false); + expect(noteBodyProps.helpPagePath).toBe(''); + }); + + it('prevents note preview xss', done => { + const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + const noteBody = ``; + const alertSpy = jest.spyOn(window, 'alert'); + store.hotUpdate({ + actions: { + updateNote() {}, + }, + }); + const noteBodyComponent = wrapper.find(NoteBody); + + noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + + setImmediate(() => { + expect(alertSpy).not.toHaveBeenCalled(); + expect(wrapper.vm.note.note_html).toEqual(escape(noteBody)); + done(); + }); + }); + + describe('cancel edit', () => { + it('restores content of updated note', done => { + const updatedText = 'updated note text'; + store.hotUpdate({ + actions: { + updateNote() {}, + }, + }); + const noteBody = wrapper.find(NoteBody); + noteBody.vm.resetAutoSave = () => {}; + + noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {}); + + wrapper.vm + .$nextTick() + .then(() => { + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(updatedText); + noteBody.vm.$emit('cancelForm'); + }) + .then(() => wrapper.vm.$nextTick()) + .then(() => { + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(note.note_html); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/notes/components/toggle_replies_widget_spec.js b/spec/frontend/notes/components/toggle_replies_widget_spec.js new file mode 100644 index 00000000000..b4f68b039cf --- /dev/null +++ b/spec/frontend/notes/components/toggle_replies_widget_spec.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import { note } from '../mock_data'; + +const deepCloneObject = obj => JSON.parse(JSON.stringify(obj)); + +describe('toggle replies widget for notes', () => { + let vm; + let ToggleRepliesWidget; + const noteFromOtherUser = deepCloneObject(note); + noteFromOtherUser.author.username = 'fatihacet'; + + const noteFromAnotherUser = deepCloneObject(note); + noteFromAnotherUser.author.username = 'mgreiling'; + noteFromAnotherUser.author.name = 'Mike Greiling'; + + const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser]; + + beforeEach(() => { + ToggleRepliesWidget = Vue.extend(toggleRepliesWidget); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed state', () => { + beforeEach(() => { + vm = mountComponent(ToggleRepliesWidget, { + replies, + collapsed: true, + }); + }); + + it('should render the collapsed', () => { + const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); + + expect(vm.$el.classList.contains('collapsed')).toEqual(true); + expect(vm.$el.querySelectorAll('.user-avatar-link').length).toEqual(3); + expect(vm.$el.querySelector('time')).not.toBeNull(); + expect(vmTextContent).toContain('5 replies'); + expect(vmTextContent).toContain(`Last reply by ${noteFromAnotherUser.author.name}`); + }); + + it('should emit toggle event when the replies text clicked', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.$el.querySelector('.js-replies-text').click(); + + expect(spy).toHaveBeenCalledWith('toggle'); + }); + }); + + describe('expanded state', () => { + beforeEach(() => { + vm = mountComponent(ToggleRepliesWidget, { + replies, + collapsed: false, + }); + }); + + it('should render expanded state', () => { + const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); + + expect(vm.$el.querySelector('.collapse-replies-btn')).not.toBeNull(); + expect(vmTextContent).toContain('Collapse replies'); + }); + + it('should emit toggle event when the collapse replies text called', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.$el.querySelector('.js-collapse-replies').click(); + + expect(spy).toHaveBeenCalledWith('toggle'); + }); + }); +}); diff --git a/spec/frontend/notes/stores/collapse_utils_spec.js b/spec/frontend/notes/stores/collapse_utils_spec.js new file mode 100644 index 00000000000..d3019f4b9a4 --- /dev/null +++ b/spec/frontend/notes/stores/collapse_utils_spec.js @@ -0,0 +1,37 @@ +import { + isDescriptionSystemNote, + getTimeDifferenceMinutes, + collapseSystemNotes, +} from '~/notes/stores/collapse_utils'; +import { notesWithDescriptionChanges, collapsedSystemNotes } from '../mock_data'; + +describe('Collapse utils', () => { + const mockSystemNote = { + note: 'changed the description', + note_html: '

changed the description

', + system: true, + created_at: '2018-05-14T21:28:00.000Z', + }; + + it('checks if a system note is of a description type', () => { + expect(isDescriptionSystemNote(mockSystemNote)).toEqual(true); + }); + + it('returns false when a system note is not a description type', () => { + expect(isDescriptionSystemNote(Object.assign({}, mockSystemNote, { note: 'foo' }))).toEqual( + false, + ); + }); + + it('gets the time difference between two notes', () => { + const anotherSystemNote = { + created_at: '2018-05-14T21:33:00.000Z', + }; + + expect(getTimeDifferenceMinutes(mockSystemNote, anotherSystemNote)).toEqual(5); + }); + + it('collapses all description system notes made within 10 minutes or less from each other', () => { + expect(collapseSystemNotes(notesWithDescriptionChanges)).toEqual(collapsedSystemNotes); + }); +}); diff --git a/spec/helpers/container_expiration_policies_helper_spec.rb b/spec/helpers/container_expiration_policies_helper_spec.rb index f7e851fb012..6dcbadd89cb 100644 --- a/spec/helpers/container_expiration_policies_helper_spec.rb +++ b/spec/helpers/container_expiration_policies_helper_spec.rb @@ -37,8 +37,8 @@ describe ContainerExpirationPoliciesHelper do expected_result = [ { key: '7d', label: '7 days until tags are automatically removed' }, { key: '14d', label: '14 days until tags are automatically removed' }, - { key: '30d', label: '30 days until tags are automatically removed', default: true }, - { key: '90d', label: '90 days until tags are automatically removed' } + { key: '30d', label: '30 days until tags are automatically removed' }, + { key: '90d', label: '90 days until tags are automatically removed', default: true } ] expect(helper.older_than_options).to eq(expected_result) diff --git a/spec/javascripts/helpers/init_vue_mr_page_helper.js b/spec/javascripts/helpers/init_vue_mr_page_helper.js index 3fa29cb9136..04f969fcd2d 100644 --- a/spec/javascripts/helpers/init_vue_mr_page_helper.js +++ b/spec/javascripts/helpers/init_vue_mr_page_helper.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import initMRPage from '~/mr_notes/index'; import axios from '~/lib/utils/axios_utils'; -import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_data'; +import { userDataMock, notesDataMock, noteableDataMock } from '../../frontend/notes/mock_data'; import diffFileMockData from '../diffs/mock_data/diff_file'; export default function initVueMRPage() { diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js deleted file mode 100644 index 573aac2c3e0..00000000000 --- a/spec/javascripts/notes/components/diff_with_note_spec.js +++ /dev/null @@ -1,89 +0,0 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers'; -import DiffWithNote from '~/notes/components/diff_with_note.vue'; -import { createStore } from '~/mr_notes/stores'; - -const discussionFixture = 'merge_requests/diff_discussion.json'; -const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; - -describe('diff_with_note', () => { - let store; - let vm; - const diffDiscussionMock = getJSONFixture(discussionFixture)[0]; - const diffDiscussion = diffDiscussionMock; - const Component = Vue.extend(DiffWithNote); - const props = { - discussion: diffDiscussion, - }; - const selectors = { - get container() { - return vm.$el; - }, - get diffTable() { - return this.container.querySelector('.diff-content table'); - }, - get diffRows() { - return this.container.querySelectorAll('.diff-content .line_holder'); - }, - get noteRow() { - return this.container.querySelector('.diff-content .notes_holder'); - }, - }; - - beforeEach(() => { - store = createStore(); - store.replaceState({ - ...store.state, - notes: { - noteableData: { - current_user: {}, - }, - }, - }); - }); - - describe('text diff', () => { - beforeEach(() => { - vm = mountComponentWithStore(Component, { props, store }); - }); - - it('removes trailing "+" char', () => { - const richText = vm.$el.querySelectorAll('.line_holder')[4].querySelector('.line_content') - .textContent[0]; - - expect(richText).not.toEqual('+'); - }); - - it('removes trailing "-" char', () => { - const richText = vm.$el.querySelector('#LC13').parentNode.textContent[0]; - - expect(richText).not.toEqual('-'); - }); - - it('shows text diff', () => { - expect(selectors.container).toHaveClass('text-file'); - expect(selectors.diffTable).toExist(); - }); - - it('shows diff lines', () => { - expect(selectors.diffRows.length).toBe(12); - }); - - it('shows notes row', () => { - expect(selectors.noteRow).toExist(); - }); - }); - - describe('image diff', () => { - beforeEach(() => { - const imageDiffDiscussionMock = getJSONFixture(imageDiscussionFixture)[0]; - props.discussion = imageDiffDiscussionMock; - }); - - it('shows image diff', () => { - vm = mountComponentWithStore(Component, { props, store }); - - expect(selectors.diffTable).not.toExist(); - }); - }); -}); diff --git a/spec/javascripts/notes/components/discussion_filter_spec.js b/spec/javascripts/notes/components/discussion_filter_spec.js deleted file mode 100644 index 7524de36ac5..00000000000 --- a/spec/javascripts/notes/components/discussion_filter_spec.js +++ /dev/null @@ -1,187 +0,0 @@ -import Vue from 'vue'; -import createStore from '~/notes/stores'; -import DiscussionFilter from '~/notes/components/discussion_filter.vue'; -import { DISCUSSION_FILTERS_DEFAULT_VALUE, DISCUSSION_FILTER_TYPES } from '~/notes/constants'; -import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { discussionFiltersMock, discussionMock } from '../mock_data'; - -describe('DiscussionFilter component', () => { - let vm; - let store; - let eventHub; - - const mountComponent = () => { - store = createStore(); - - const discussions = [ - { - ...discussionMock, - id: discussionMock.id, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], - }, - ]; - const Component = Vue.extend(DiscussionFilter); - const selectedValue = DISCUSSION_FILTERS_DEFAULT_VALUE; - const props = { filters: discussionFiltersMock, selectedValue }; - - store.state.discussions = discussions; - return mountComponentWithStore(Component, { - el: null, - store, - props, - }); - }; - - beforeEach(() => { - window.mrTabs = undefined; - vm = mountComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders the all filters', () => { - expect(vm.$el.querySelectorAll('.dropdown-menu li').length).toEqual( - discussionFiltersMock.length, - ); - }); - - it('renders the default selected item', () => { - expect(vm.$el.querySelector('#discussion-filter-dropdown').textContent.trim()).toEqual( - discussionFiltersMock[0].title, - ); - }); - - it('updates to the selected item', () => { - const filterItem = vm.$el.querySelector( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, - ); - filterItem.click(); - - expect(vm.currentFilter.title).toEqual(filterItem.textContent.trim()); - }); - - it('only updates when selected filter changes', () => { - const filterItem = vm.$el.querySelector( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`, - ); - - spyOn(vm, 'filterDiscussion'); - filterItem.click(); - - expect(vm.filterDiscussion).not.toHaveBeenCalled(); - }); - - it('disables commenting when "Show history only" filter is applied', () => { - const filterItem = vm.$el.querySelector( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, - ); - filterItem.click(); - - expect(vm.$store.state.commentsDisabled).toBe(true); - }); - - it('enables commenting when "Show history only" filter is not applied', () => { - const filterItem = vm.$el.querySelector( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`, - ); - filterItem.click(); - - expect(vm.$store.state.commentsDisabled).toBe(false); - }); - - it('renders a dropdown divider for the default filter', () => { - const defaultFilter = vm.$el.querySelector( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`, - ); - - expect(defaultFilter.lastChild.classList).toContain('dropdown-divider'); - }); - - describe('Merge request tabs', () => { - eventHub = new Vue(); - - beforeEach(() => { - window.mrTabs = { - eventHub, - currentTab: 'show', - }; - - vm = mountComponent(); - }); - - afterEach(() => { - window.mrTabs = undefined; - }); - - it('only renders when discussion tab is active', done => { - eventHub.$emit('MergeRequestTabChange', 'commit'); - - vm.$nextTick(() => { - expect(vm.$el.querySelector).toBeUndefined(); - done(); - }); - }); - }); - - describe('URL with Links to notes', () => { - afterEach(() => { - window.location.hash = ''; - }); - - it('updates the filter when the URL links to a note', done => { - window.location.hash = `note_${discussionMock.notes[0].id}`; - vm.currentValue = discussionFiltersMock[2].value; - vm.handleLocationHash(); - - vm.$nextTick(() => { - expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE); - done(); - }); - }); - - it('does not update the filter when the current filter is "Show all activity"', done => { - window.location.hash = `note_${discussionMock.notes[0].id}`; - vm.handleLocationHash(); - - vm.$nextTick(() => { - expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE); - done(); - }); - }); - - it('only updates filter when the URL links to a note', done => { - window.location.hash = `testing123`; - vm.handleLocationHash(); - - vm.$nextTick(() => { - expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE); - done(); - }); - }); - - it('fetches discussions when there is a hash', done => { - window.location.hash = `note_${discussionMock.notes[0].id}`; - vm.currentValue = discussionFiltersMock[2].value; - spyOn(vm, 'selectFilter'); - vm.handleLocationHash(); - - vm.$nextTick(() => { - expect(vm.selectFilter).toHaveBeenCalled(); - done(); - }); - }); - - it('does not fetch discussions when there is no hash', done => { - window.location.hash = ''; - spyOn(vm, 'selectFilter'); - vm.handleLocationHash(); - - vm.$nextTick(() => { - expect(vm.selectFilter).not.toHaveBeenCalled(); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js deleted file mode 100644 index 4348445f7ca..00000000000 --- a/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { TEST_HOST } from 'spec/test_constants'; -import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; - -const localVue = createLocalVue(); - -describe('ResolveWithIssueButton', () => { - let wrapper; - const url = `${TEST_HOST}/hello-world/`; - - beforeEach(() => { - wrapper = shallowMount(ResolveWithIssueButton, { - localVue, - propsData: { - url, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('it should have a link with the provided link property as href', () => { - const button = wrapper.find(GlButton); - - expect(button.attributes().href).toBe(url); - }); -}); diff --git a/spec/javascripts/notes/components/note_actions/reply_button_spec.js b/spec/javascripts/notes/components/note_actions/reply_button_spec.js deleted file mode 100644 index 720ab10b270..00000000000 --- a/spec/javascripts/notes/components/note_actions/reply_button_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ReplyButton', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(localVue.extend(ReplyButton), { - localVue, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('emits startReplying on click', () => { - const button = wrapper.find({ ref: 'button' }); - - button.trigger('click'); - - expect(wrapper.emitted().startReplying).toBeTruthy(); - expect(wrapper.emitted().startReplying.length).toBe(1); - }); -}); diff --git a/spec/javascripts/notes/components/note_actions_spec.js b/spec/javascripts/notes/components/note_actions_spec.js deleted file mode 100644 index 5d13f587ca7..00000000000 --- a/spec/javascripts/notes/components/note_actions_spec.js +++ /dev/null @@ -1,160 +0,0 @@ -import Vue from 'vue'; -import { shallowMount, createLocalVue, createWrapper } from '@vue/test-utils'; -import { TEST_HOST } from 'spec/test_constants'; -import createStore from '~/notes/stores'; -import noteActions from '~/notes/components/note_actions.vue'; -import { userDataMock } from '../mock_data'; - -describe('noteActions', () => { - let wrapper; - let store; - let props; - - const shallowMountNoteActions = propsData => { - const localVue = createLocalVue(); - return shallowMount(localVue.extend(noteActions), { - store, - propsData, - localVue, - }); - }; - - beforeEach(() => { - store = createStore(); - props = { - accessLevel: 'Maintainer', - authorId: 26, - canDelete: true, - canEdit: true, - canAwardEmoji: true, - canReportAsAbuse: true, - noteId: '539', - noteUrl: `${TEST_HOST}/group/project/-/merge_requests/1#note_1`, - reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`, - showReply: false, - }; - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('user is logged in', () => { - beforeEach(() => { - store.dispatch('setUserData', userDataMock); - - wrapper = shallowMountNoteActions(props); - }); - - it('should render access level badge', () => { - expect( - wrapper - .find('.note-role') - .text() - .trim(), - ).toEqual(props.accessLevel); - }); - - it('should render emoji link', () => { - expect(wrapper.find('.js-add-award').exists()).toBe(true); - expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right'); - }); - - describe('actions dropdown', () => { - it('should be possible to edit the comment', () => { - expect(wrapper.find('.js-note-edit').exists()).toBe(true); - }); - - it('should be possible to report abuse to admin', () => { - expect(wrapper.find(`a[href="${props.reportAbusePath}"]`).exists()).toBe(true); - }); - - it('should be possible to copy link to a note', () => { - expect(wrapper.find('.js-btn-copy-note-link').exists()).toBe(true); - }); - - it('should not show copy link action when `noteUrl` prop is empty', done => { - wrapper.setProps({ - ...props, - noteUrl: '', - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-btn-copy-note-link').exists()).toBe(false); - }) - .then(done) - .catch(done.fail); - }); - - it('should be possible to delete comment', () => { - expect(wrapper.find('.js-note-delete').exists()).toBe(true); - }); - - it('closes tooltip when dropdown opens', done => { - wrapper.find('.more-actions-toggle').trigger('click'); - - const rootWrapper = createWrapper(wrapper.vm.$root); - Vue.nextTick() - .then(() => { - const emitted = Object.keys(rootWrapper.emitted()); - - expect(emitted).toEqual(['bv::hide::tooltip']); - done(); - }) - .catch(done.fail); - }); - }); - }); - - describe('user is not logged in', () => { - beforeEach(() => { - store.dispatch('setUserData', {}); - wrapper = shallowMountNoteActions({ - ...props, - canDelete: false, - canEdit: false, - canAwardEmoji: false, - canReportAsAbuse: false, - }); - }); - - it('should not render emoji link', () => { - expect(wrapper.find('.js-add-award').exists()).toBe(false); - }); - - it('should not render actions dropdown', () => { - expect(wrapper.find('.more-actions').exists()).toBe(false); - }); - }); - - describe('for showReply = true', () => { - beforeEach(() => { - wrapper = shallowMountNoteActions({ - ...props, - showReply: true, - }); - }); - - it('shows a reply button', () => { - const replyButton = wrapper.find({ ref: 'replyButton' }); - - expect(replyButton.exists()).toBe(true); - }); - }); - - describe('for showReply = false', () => { - beforeEach(() => { - wrapper = shallowMountNoteActions({ - ...props, - showReply: false, - }); - }); - - it('does not show a reply button', () => { - const replyButton = wrapper.find({ ref: 'replyButton' }); - - expect(replyButton.exists()).toBe(false); - }); - }); -}); diff --git a/spec/javascripts/notes/components/note_awards_list_spec.js b/spec/javascripts/notes/components/note_awards_list_spec.js deleted file mode 100644 index 90aa1684272..00000000000 --- a/spec/javascripts/notes/components/note_awards_list_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import Vue from 'vue'; -import createStore from '~/notes/stores'; -import awardsNote from '~/notes/components/note_awards_list.vue'; -import { noteableDataMock, notesDataMock } from '../mock_data'; - -describe('note_awards_list component', () => { - let store; - let vm; - let awardsMock; - - beforeEach(() => { - const Component = Vue.extend(awardsNote); - - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - awardsMock = [ - { - name: 'flag_tz', - user: { id: 1, name: 'Administrator', username: 'root' }, - }, - { - name: 'cartwheel_tone3', - user: { id: 12, name: 'Bobbie Stehr', username: 'erin' }, - }, - ]; - - vm = new Component({ - store, - propsData: { - awards: awardsMock, - noteAuthorId: 2, - noteId: '545', - canAwardEmoji: true, - toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', - }, - }).$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render awarded emojis', () => { - expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined(); - expect( - vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]'), - ).toBeDefined(); - }); - - it('should be possible to remove awarded emoji', () => { - spyOn(vm, 'handleAward').and.callThrough(); - spyOn(vm, 'toggleAwardRequest').and.callThrough(); - vm.$el.querySelector('.js-awards-block button').click(); - - expect(vm.handleAward).toHaveBeenCalledWith('flag_tz'); - expect(vm.toggleAwardRequest).toHaveBeenCalled(); - }); - - it('should be possible to add new emoji', () => { - expect(vm.$el.querySelector('.js-add-award')).toBeDefined(); - }); - - describe('when the user name contains special HTML characters', () => { - const createAwardEmoji = (_, index) => ({ - name: 'art', - user: { id: index, name: `&<>"\`'-${index}`, username: `user-${index}` }, - }); - - const mountComponent = () => { - const Component = Vue.extend(awardsNote); - vm = new Component({ - store, - propsData: { - awards: awardsMock, - noteAuthorId: 0, - noteId: '545', - canAwardEmoji: true, - toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', - }, - }).$mount(); - }; - - const findTooltip = () => - vm.$el.querySelector('[data-original-title]').getAttribute('data-original-title'); - - it('should only escape & and " characters', () => { - awardsMock = [...new Array(1)].map(createAwardEmoji); - mountComponent(); - const escapedName = awardsMock[0].user.name.replace(/&/g, '&').replace(/"/g, '"'); - - expect(vm.$el.querySelector('[data-original-title]').outerHTML).toContain(escapedName); - }); - - it('should not escape special HTML characters twice when only 1 person awarded', () => { - awardsMock = [...new Array(1)].map(createAwardEmoji); - mountComponent(); - - awardsMock.forEach(award => { - expect(findTooltip()).toContain(award.user.name); - }); - }); - - it('should not escape special HTML characters twice when 2 people awarded', () => { - awardsMock = [...new Array(2)].map(createAwardEmoji); - mountComponent(); - - awardsMock.forEach(award => { - expect(findTooltip()).toContain(award.user.name); - }); - }); - - it('should not escape special HTML characters twice when more than 10 people awarded', () => { - awardsMock = [...new Array(11)].map(createAwardEmoji); - mountComponent(); - - // Testing only the first 10 awards since 11 onward will not be displayed. - awardsMock.slice(0, 10).forEach(award => { - expect(findTooltip()).toContain(award.user.name); - }); - }); - }); - - describe('when the user cannot award emoji', () => { - beforeEach(() => { - const Component = Vue.extend(awardsNote); - - vm = new Component({ - store, - propsData: { - awards: awardsMock, - noteAuthorId: 2, - noteId: '545', - canAwardEmoji: false, - toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', - }, - }).$mount(); - }); - - it('should not be possible to remove awarded emoji', () => { - spyOn(vm, 'toggleAwardRequest').and.callThrough(); - - vm.$el.querySelector('.js-awards-block button').click(); - - expect(vm.toggleAwardRequest).not.toHaveBeenCalled(); - }); - - it('should not be possible to add new emoji', () => { - expect(vm.$el.querySelector('.js-add-award')).toBeNull(); - }); - }); -}); diff --git a/spec/javascripts/notes/components/note_body_spec.js b/spec/javascripts/notes/components/note_body_spec.js deleted file mode 100644 index efad0785afe..00000000000 --- a/spec/javascripts/notes/components/note_body_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import Vue from 'vue'; -import createStore from '~/notes/stores'; -import noteBody from '~/notes/components/note_body.vue'; -import { noteableDataMock, notesDataMock, note } from '../mock_data'; - -describe('issue_note_body component', () => { - let store; - let vm; - - beforeEach(() => { - const Component = Vue.extend(noteBody); - - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - vm = new Component({ - store, - propsData: { - note, - canEdit: true, - canAwardEmoji: true, - }, - }).$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render the note', () => { - expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); - }); - - it('should render awards list', () => { - expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull(); - expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull(); - }); - - describe('isEditing', () => { - beforeEach(done => { - vm.isEditing = true; - Vue.nextTick(done); - }); - - it('renders edit form', () => { - expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull(); - }); - - it('adds autosave', () => { - const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; - - expect(vm.autosave).toExist(); - expect(vm.autosave.key).toEqual(autosaveKey); - }); - }); -}); diff --git a/spec/javascripts/notes/components/note_form_spec.js b/spec/javascripts/notes/components/note_form_spec.js deleted file mode 100644 index 8ab8bce9027..00000000000 --- a/spec/javascripts/notes/components/note_form_spec.js +++ /dev/null @@ -1,267 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import createStore from '~/notes/stores'; -import NoteForm from '~/notes/components/note_form.vue'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { noteableDataMock, notesDataMock } from '../mock_data'; - -describe('issue_note_form component', () => { - const dummyAutosaveKey = 'some-autosave-key'; - const dummyDraft = 'dummy draft content'; - - let store; - let wrapper; - let props; - - const createComponentWrapper = () => { - const localVue = createLocalVue(); - return shallowMount(localVue.extend(NoteForm), { - store, - propsData: props, - // see https://gitlab.com/gitlab-org/gitlab-foss/issues/56317 for the following - localVue, - }); - }; - - beforeEach(() => { - spyOnDependency(NoteForm, 'getDraft').and.callFake(key => { - if (key === dummyAutosaveKey) { - return dummyDraft; - } - - return null; - }); - - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - props = { - isEditing: false, - noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', - noteId: '545', - }; - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('noteHash', () => { - beforeEach(() => { - wrapper = createComponentWrapper(); - }); - - it('returns note hash string based on `noteId`', () => { - expect(wrapper.vm.noteHash).toBe(`#note_${props.noteId}`); - }); - - it('return note hash as `#` when `noteId` is empty', done => { - wrapper.setProps({ - ...props, - noteId: '', - }); - - wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.vm.noteHash).toBe('#'); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('conflicts editing', () => { - beforeEach(() => { - wrapper = createComponentWrapper(); - }); - - it('should show conflict message if note changes outside the component', done => { - wrapper.setProps({ - ...props, - isEditing: true, - noteBody: 'Foo', - }); - - const message = - 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; - - wrapper.vm - .$nextTick() - .then(() => { - const conflictWarning = wrapper.find('.js-conflict-edit-warning'); - - expect(conflictWarning.exists()).toBe(true); - expect( - conflictWarning - .text() - .replace(/\s+/g, ' ') - .trim(), - ).toBe(message); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('form', () => { - beforeEach(() => { - wrapper = createComponentWrapper(); - }); - - it('should render text area with placeholder', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.attributes('placeholder')).toEqual( - 'Write a comment or drag your files hereā€¦', - ); - }); - - it('should link to markdown docs', () => { - const { markdownDocsPath } = notesDataMock; - const markdownField = wrapper.find(MarkdownField); - const markdownFieldProps = markdownField.props(); - - expect(markdownFieldProps.markdownDocsPath).toBe(markdownDocsPath); - }); - - describe('keyboard events', () => { - let textarea; - - beforeEach(() => { - textarea = wrapper.find('textarea'); - textarea.setValue('Foo'); - }); - - describe('up', () => { - it('should ender edit mode', () => { - // TODO: do not spy on vm - spyOn(wrapper.vm, 'editMyLastNote').and.callThrough(); - - textarea.trigger('keydown.up'); - - expect(wrapper.vm.editMyLastNote).toHaveBeenCalled(); - }); - }); - - describe('enter', () => { - it('should save note when cmd+enter is pressed', () => { - textarea.trigger('keydown.enter', { metaKey: true }); - - const { handleFormUpdate } = wrapper.emitted(); - - expect(handleFormUpdate.length).toBe(1); - }); - - it('should save note when ctrl+enter is pressed', () => { - textarea.trigger('keydown.enter', { ctrlKey: true }); - - const { handleFormUpdate } = wrapper.emitted(); - - expect(handleFormUpdate.length).toBe(1); - }); - }); - }); - - describe('actions', () => { - it('should be possible to cancel', done => { - // TODO: do not spy on vm - spyOn(wrapper.vm, 'cancelHandler').and.callThrough(); - wrapper.setProps({ - ...props, - isEditing: true, - }); - - wrapper.vm - .$nextTick() - .then(() => { - const cancelButton = wrapper.find('.note-edit-cancel'); - cancelButton.trigger('click'); - - expect(wrapper.vm.cancelHandler).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('should be possible to update the note', done => { - wrapper.setProps({ - ...props, - isEditing: true, - }); - - wrapper.vm - .$nextTick() - .then(() => { - const textarea = wrapper.find('textarea'); - textarea.setValue('Foo'); - const saveButton = wrapper.find('.js-vue-issue-save'); - saveButton.trigger('click'); - - expect(wrapper.vm.isSubmitting).toEqual(true); - }) - .then(done) - .catch(done.fail); - }); - }); - }); - - describe('with autosaveKey', () => { - describe('with draft', () => { - beforeEach(done => { - Object.assign(props, { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }); - wrapper = createComponentWrapper(); - - wrapper.vm - .$nextTick() - .then(done) - .catch(done.fail); - }); - - it('displays the draft in textarea', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.element.value).toBe(dummyDraft); - }); - }); - - describe('without draft', () => { - beforeEach(done => { - Object.assign(props, { - noteBody: '', - autosaveKey: 'some key without draft', - }); - wrapper = createComponentWrapper(); - - wrapper.vm - .$nextTick() - .then(done) - .catch(done.fail); - }); - - it('leaves the textarea empty', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.element.value).toBe(''); - }); - }); - - it('updates the draft if textarea content changes', () => { - const updateDraftSpy = spyOnDependency(NoteForm, 'updateDraft').and.stub(); - Object.assign(props, { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }); - wrapper = createComponentWrapper(); - const textarea = wrapper.find('textarea'); - const dummyContent = 'some new content'; - - textarea.setValue(dummyContent); - - expect(updateDraftSpy).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); - }); - }); -}); diff --git a/spec/javascripts/notes/components/note_signed_out_widget_spec.js b/spec/javascripts/notes/components/note_signed_out_widget_spec.js deleted file mode 100644 index e217a2caa73..00000000000 --- a/spec/javascripts/notes/components/note_signed_out_widget_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import Vue from 'vue'; -import noteSignedOut from '~/notes/components/note_signed_out_widget.vue'; -import createStore from '~/notes/stores'; -import { notesDataMock } from '../mock_data'; - -describe('note_signed_out_widget component', () => { - let store; - let vm; - - beforeEach(() => { - const Component = Vue.extend(noteSignedOut); - store = createStore(); - store.dispatch('setNotesData', notesDataMock); - - vm = new Component({ - store, - }).$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render sign in link provided in the store', () => { - expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual( - 'sign in', - ); - }); - - it('should render register link provided in the store', () => { - expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual( - 'register', - ); - }); - - it('should render information text', () => { - expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual( - 'Please register or sign in to reply', - ); - }); -}); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js deleted file mode 100644 index ee84fd2b091..00000000000 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ /dev/null @@ -1,195 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import createStore from '~/notes/stores'; -import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; -import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; -import NoteForm from '~/notes/components/note_form.vue'; -import '~/behaviors/markdown/render_gfm'; -import { - noteableDataMock, - discussionMock, - notesDataMock, - loggedOutnoteableData, - userDataMock, -} from '../mock_data'; -import mockDiffFile from '../../diffs/mock_data/diff_file'; -import { trimText } from '../../helpers/text_helper'; - -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - -const localVue = createLocalVue(); - -describe('noteable_discussion component', () => { - let store; - let wrapper; - let originalGon; - - preloadFixtures(discussionWithTwoUnresolvedNotes); - - beforeEach(() => { - window.mrTabs = {}; - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - wrapper = mount(localVue.extend(noteableDiscussion), { - store, - propsData: { discussion: discussionMock }, - localVue, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should not render thread header for non diff threads', () => { - expect(wrapper.find('.discussion-header').exists()).toBe(false); - }); - - it('should render thread header', done => { - const discussion = { ...discussionMock }; - discussion.diff_file = mockDiffFile; - discussion.diff_discussion = true; - - wrapper.setProps({ discussion }); - - wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.find('.discussion-header').exists()).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - - describe('actions', () => { - it('should toggle reply form', done => { - const replyPlaceholder = wrapper.find(ReplyPlaceholder); - - wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.vm.isReplying).toEqual(false); - - replyPlaceholder.vm.$emit('onClick'); - }) - .then(() => wrapper.vm.$nextTick()) - .then(() => { - expect(wrapper.vm.isReplying).toEqual(true); - - const noteForm = wrapper.find(NoteForm); - - expect(noteForm.exists()).toBe(true); - - const noteFormProps = noteForm.props(); - - expect(noteFormProps.discussion).toBe(discussionMock); - expect(noteFormProps.isEditing).toBe(false); - expect(noteFormProps.line).toBe(null); - expect(noteFormProps.saveButtonTitle).toBe('Comment'); - expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`); - }) - .then(done) - .catch(done.fail); - }); - - it('does not render jump to thread button', () => { - expect(wrapper.find('*[data-original-title="Jump to next unresolved thread"]').exists()).toBe( - false, - ); - }); - }); - - describe('for resolved thread', () => { - beforeEach(() => { - const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; - wrapper.setProps({ discussion }); - }); - - it('does not display a button to resolve with issue', () => { - const button = wrapper.find(ResolveWithIssueButton); - - expect(button.exists()).toBe(false); - }); - }); - - describe('for unresolved thread', () => { - beforeEach(done => { - const discussion = { - ...getJSONFixture(discussionWithTwoUnresolvedNotes)[0], - expanded: true, - }; - discussion.notes = discussion.notes.map(note => ({ - ...note, - resolved: false, - current_user: { - ...note.current_user, - can_resolve: true, - }, - })); - - wrapper.setProps({ discussion }); - - wrapper.vm - .$nextTick() - .then(done) - .catch(done.fail); - }); - - it('displays a button to resolve with issue', () => { - const button = wrapper.find(ResolveWithIssueButton); - - expect(button.exists()).toBe(true); - }); - }); - - describe('signout widget', () => { - beforeEach(() => { - originalGon = Object.assign({}, window.gon); - window.gon = window.gon || {}; - }); - - afterEach(() => { - wrapper.destroy(); - window.gon = originalGon; - }); - - describe('user is logged in', () => { - beforeEach(() => { - window.gon.current_user_id = userDataMock.id; - store.dispatch('setUserData', userDataMock); - - wrapper = mount(localVue.extend(noteableDiscussion), { - store, - propsData: { discussion: discussionMock }, - localVue, - }); - }); - - it('should not render signed out widget', () => { - expect(Boolean(wrapper.vm.isLoggedIn)).toBe(true); - expect(trimText(wrapper.text())).not.toContain('Please register or sign in to reply'); - }); - }); - - describe('user is not logged in', () => { - beforeEach(() => { - window.gon.current_user_id = null; - store.dispatch('setNoteableData', loggedOutnoteableData); - store.dispatch('setNotesData', notesDataMock); - - wrapper = mount(localVue.extend(noteableDiscussion), { - store, - propsData: { discussion: discussionMock }, - localVue, - }); - }); - - it('should render signed out widget', () => { - expect(Boolean(wrapper.vm.isLoggedIn)).toBe(false); - expect(trimText(wrapper.text())).toContain('Please register or sign in to reply'); - }); - }); - }); -}); diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js deleted file mode 100644 index 1906dae7800..00000000000 --- a/spec/javascripts/notes/components/noteable_note_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -import { escape } from 'lodash'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import createStore from '~/notes/stores'; -import issueNote from '~/notes/components/noteable_note.vue'; -import NoteHeader from '~/notes/components/note_header.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import NoteActions from '~/notes/components/note_actions.vue'; -import NoteBody from '~/notes/components/note_body.vue'; -import { noteableDataMock, notesDataMock, note } from '../mock_data'; - -describe('issue_note', () => { - let store; - let wrapper; - - beforeEach(() => { - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - const localVue = createLocalVue(); - wrapper = shallowMount(localVue.extend(issueNote), { - store, - propsData: { - note, - }, - localVue, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render user information', () => { - const { author } = note; - const avatar = wrapper.find(UserAvatarLink); - const avatarProps = avatar.props(); - - expect(avatarProps.linkHref).toBe(author.path); - expect(avatarProps.imgSrc).toBe(author.avatar_url); - expect(avatarProps.imgAlt).toBe(author.name); - expect(avatarProps.imgSize).toBe(40); - }); - - it('should render note header content', () => { - const noteHeader = wrapper.find(NoteHeader); - const noteHeaderProps = noteHeader.props(); - - expect(noteHeaderProps.author).toEqual(note.author); - expect(noteHeaderProps.createdAt).toEqual(note.created_at); - expect(noteHeaderProps.noteId).toEqual(note.id); - }); - - it('should render note actions', () => { - const { author } = note; - const noteActions = wrapper.find(NoteActions); - const noteActionsProps = noteActions.props(); - - expect(noteActionsProps.authorId).toBe(author.id); - expect(noteActionsProps.noteId).toBe(note.id); - expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); - expect(noteActionsProps.accessLevel).toBe(note.human_access); - expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); - expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); - expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); - expect(noteActionsProps.canReportAsAbuse).toBe(true); - expect(noteActionsProps.canResolve).toBe(false); - expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); - expect(noteActionsProps.resolvable).toBe(false); - expect(noteActionsProps.isResolved).toBe(false); - expect(noteActionsProps.isResolving).toBe(false); - expect(noteActionsProps.resolvedBy).toEqual({}); - }); - - it('should render issue body', () => { - const noteBody = wrapper.find(NoteBody); - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note).toEqual(note); - expect(noteBodyProps.line).toBe(null); - expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); - expect(noteBodyProps.isEditing).toBe(false); - expect(noteBodyProps.helpPagePath).toBe(''); - }); - - it('prevents note preview xss', done => { - const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - const noteBody = ``; - const alertSpy = spyOn(window, 'alert'); - store.hotUpdate({ - actions: { - updateNote() {}, - }, - }); - const noteBodyComponent = wrapper.find(NoteBody); - - noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); - - setTimeout(() => { - expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.vm.note.note_html).toEqual(escape(noteBody)); - done(); - }, 0); - }); - - describe('cancel edit', () => { - it('restores content of updated note', done => { - const updatedText = 'updated note text'; - store.hotUpdate({ - actions: { - updateNote() {}, - }, - }); - const noteBody = wrapper.find(NoteBody); - noteBody.vm.resetAutoSave = () => {}; - - noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {}); - - wrapper.vm - .$nextTick() - .then(() => { - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note.note_html).toBe(updatedText); - noteBody.vm.$emit('cancelForm'); - }) - .then(() => wrapper.vm.$nextTick()) - .then(() => { - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note.note_html).toBe(note.note_html); - }) - .then(done) - .catch(done.fail); - }); - }); -}); diff --git a/spec/javascripts/notes/components/toggle_replies_widget_spec.js b/spec/javascripts/notes/components/toggle_replies_widget_spec.js deleted file mode 100644 index 8485ec0262f..00000000000 --- a/spec/javascripts/notes/components/toggle_replies_widget_spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import toggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; -import { note } from '../mock_data'; - -const deepCloneObject = obj => JSON.parse(JSON.stringify(obj)); - -describe('toggle replies widget for notes', () => { - let vm; - let ToggleRepliesWidget; - const noteFromOtherUser = deepCloneObject(note); - noteFromOtherUser.author.username = 'fatihacet'; - - const noteFromAnotherUser = deepCloneObject(note); - noteFromAnotherUser.author.username = 'mgreiling'; - noteFromAnotherUser.author.name = 'Mike Greiling'; - - const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser]; - - beforeEach(() => { - ToggleRepliesWidget = Vue.extend(toggleRepliesWidget); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('collapsed state', () => { - beforeEach(() => { - vm = mountComponent(ToggleRepliesWidget, { - replies, - collapsed: true, - }); - }); - - it('should render the collapsed', () => { - const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); - - expect(vm.$el.classList.contains('collapsed')).toEqual(true); - expect(vm.$el.querySelectorAll('.user-avatar-link').length).toEqual(3); - expect(vm.$el.querySelector('time')).not.toBeNull(); - expect(vmTextContent).toContain('5 replies'); - expect(vmTextContent).toContain(`Last reply by ${noteFromAnotherUser.author.name}`); - }); - - it('should emit toggle event when the replies text clicked', () => { - const spy = spyOn(vm, '$emit'); - - vm.$el.querySelector('.js-replies-text').click(); - - expect(spy).toHaveBeenCalledWith('toggle'); - }); - }); - - describe('expanded state', () => { - beforeEach(() => { - vm = mountComponent(ToggleRepliesWidget, { - replies, - collapsed: false, - }); - }); - - it('should render expanded state', () => { - const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); - - expect(vm.$el.querySelector('.collapse-replies-btn')).not.toBeNull(); - expect(vmTextContent).toContain('Collapse replies'); - }); - - it('should emit toggle event when the collapse replies text called', () => { - const spy = spyOn(vm, '$emit'); - - vm.$el.querySelector('.js-collapse-replies').click(); - - expect(spy).toHaveBeenCalledWith('toggle'); - }); - }); -}); diff --git a/spec/javascripts/notes/helpers.js b/spec/javascripts/notes/helpers.js deleted file mode 100644 index 7bcba609311..00000000000 --- a/spec/javascripts/notes/helpers.js +++ /dev/null @@ -1 +0,0 @@ -export * from '../../frontend/notes/helpers.js'; diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js deleted file mode 100644 index 89e4553092a..00000000000 --- a/spec/javascripts/notes/mock_data.js +++ /dev/null @@ -1 +0,0 @@ -export * from '../../frontend/notes/mock_data.js'; diff --git a/spec/javascripts/notes/stores/collapse_utils_spec.js b/spec/javascripts/notes/stores/collapse_utils_spec.js deleted file mode 100644 index d3019f4b9a4..00000000000 --- a/spec/javascripts/notes/stores/collapse_utils_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { - isDescriptionSystemNote, - getTimeDifferenceMinutes, - collapseSystemNotes, -} from '~/notes/stores/collapse_utils'; -import { notesWithDescriptionChanges, collapsedSystemNotes } from '../mock_data'; - -describe('Collapse utils', () => { - const mockSystemNote = { - note: 'changed the description', - note_html: '

changed the description

', - system: true, - created_at: '2018-05-14T21:28:00.000Z', - }; - - it('checks if a system note is of a description type', () => { - expect(isDescriptionSystemNote(mockSystemNote)).toEqual(true); - }); - - it('returns false when a system note is not a description type', () => { - expect(isDescriptionSystemNote(Object.assign({}, mockSystemNote, { note: 'foo' }))).toEqual( - false, - ); - }); - - it('gets the time difference between two notes', () => { - const anotherSystemNote = { - created_at: '2018-05-14T21:33:00.000Z', - }; - - expect(getTimeDifferenceMinutes(mockSystemNote, anotherSystemNote)).toEqual(5); - }); - - it('collapses all description system notes made within 10 minutes or less from each other', () => { - expect(collapseSystemNotes(notesWithDescriptionChanges)).toEqual(collapsedSystemNotes); - }); -}); diff --git a/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb index 59639409183..5f80ef9538a 100644 --- a/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb @@ -9,7 +9,14 @@ describe Gitlab::SidekiqMiddleware::ClientMetrics do let(:queue) { :test } let(:worker_class) { worker.class } let(:job) { {} } - let(:default_labels) { { queue: queue.to_s, boundary: "", external_dependencies: "no", feature_category: "", urgency: "low" } } + let(:default_labels) do + { queue: queue.to_s, + worker: worker_class.to_s, + boundary: "", + external_dependencies: "no", + feature_category: "", + urgency: "low" } + end shared_examples "a metrics client middleware" do context "with mocked prometheus" do diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb index 3343587beff..3214bd758e7 100644 --- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb @@ -11,7 +11,14 @@ describe Gitlab::SidekiqMiddleware::ServerMetrics do let(:job) { {} } let(:job_status) { :done } let(:labels_with_job_status) { labels.merge(job_status: job_status.to_s) } - let(:default_labels) { { queue: queue.to_s, boundary: "", external_dependencies: "no", feature_category: "", urgency: "low" } } + let(:default_labels) do + { queue: queue.to_s, + worker: worker_class.to_s, + boundary: "", + external_dependencies: "no", + feature_category: "", + urgency: "low" } + end shared_examples "a metrics middleware" do context "with mocked prometheus" do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 425194ba0e3..844e50dbb58 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1924,7 +1924,7 @@ describe Ci::Pipeline, :mailer do describe '#update_status' do context 'when pipeline is empty' do it 'updates does not change pipeline status' do - expect(pipeline.statuses.latest.slow_composite_status).to be_nil + expect(pipeline.statuses.latest.slow_composite_status(project: project)).to be_nil expect { pipeline.update_legacy_status } .to change { pipeline.reload.status } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 40d9afcdd14..73b81b2225a 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -423,7 +423,7 @@ describe CommitStatus do end it 'returns a correct compound status' do - expect(described_class.all.slow_composite_status).to eq 'running' + expect(described_class.all.slow_composite_status(project: project)).to eq 'running' end end @@ -433,7 +433,7 @@ describe CommitStatus do end it 'returns status that indicates success' do - expect(described_class.all.slow_composite_status).to eq 'success' + expect(described_class.all.slow_composite_status(project: project)).to eq 'success' end end @@ -444,7 +444,7 @@ describe CommitStatus do end it 'returns status according to the scope' do - expect(described_class.latest.slow_composite_status).to eq 'success' + expect(described_class.latest.slow_composite_status(project: project)).to eq 'success' end end end diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb index 99d09af80d0..68047f24ec3 100644 --- a/spec/models/concerns/has_status_spec.rb +++ b/spec/models/concerns/has_status_spec.rb @@ -6,7 +6,7 @@ describe HasStatus do describe '.slow_composite_status' do using RSpec::Parameterized::TableSyntax - subject { CommitStatus.slow_composite_status } + subject { CommitStatus.slow_composite_status(project: nil) } shared_examples 'build status summary' do context 'all successful' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 61c871ead92..291c628bfde 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -4400,6 +4400,12 @@ describe User, :do_not_mock_admin_mode do it { is_expected.to be expected_result } end + + context 'when email is of Gitlab and is not confirmed' do + let(:user) { build(:user, email: 'test@gitlab.com', confirmed_at: nil) } + + it { is_expected.to be false } + end end describe '#current_highest_access_level' do -- cgit v1.2.3