summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/autosave.js24
-rw-r--r--app/assets/javascripts/awards_handler.js30
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js5
-rw-r--r--app/assets/javascripts/dispatcher.js3
-rw-r--r--app/assets/javascripts/dropzone_input.js4
-rw-r--r--app/assets/javascripts/fly_out_nav.js14
-rw-r--r--app/assets/javascripts/issue.js7
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue12
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue8
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue12
-rw-r--r--app/assets/javascripts/issue_show/index.js6
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js14
-rw-r--r--app/assets/javascripts/notes/components/issue_comment_form.vue347
-rw-r--r--app/assets/javascripts/notes/components/issue_discussion.vue232
-rw-r--r--app/assets/javascripts/notes/components/issue_note.vue186
-rw-r--r--app/assets/javascripts/notes/components/issue_note_actions.vue167
-rw-r--r--app/assets/javascripts/notes/components/issue_note_attachment.vue37
-rw-r--r--app/assets/javascripts/notes/components/issue_note_awards_list.vue228
-rw-r--r--app/assets/javascripts/notes/components/issue_note_body.vue122
-rw-r--r--app/assets/javascripts/notes/components/issue_note_edited_text.vue47
-rw-r--r--app/assets/javascripts/notes/components/issue_note_form.vue166
-rw-r--r--app/assets/javascripts/notes/components/issue_note_header.vue118
-rw-r--r--app/assets/javascripts/notes/components/issue_note_icons.js37
-rw-r--r--app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue28
-rw-r--r--app/assets/javascripts/notes/components/issue_notes_app.vue151
-rw-r--r--app/assets/javascripts/notes/components/issue_placeholder_note.vue53
-rw-r--r--app/assets/javascripts/notes/components/issue_placeholder_system_note.vue21
-rw-r--r--app/assets/javascripts/notes/components/issue_system_note.vue55
-rw-r--r--app/assets/javascripts/notes/constants.js11
-rw-r--r--app/assets/javascripts/notes/event_hub.js3
-rw-r--r--app/assets/javascripts/notes/index.js35
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js16
-rw-r--r--app/assets/javascripts/notes/services/issue_notes_service.js35
-rw-r--r--app/assets/javascripts/notes/stores/actions.js217
-rw-r--r--app/assets/javascripts/notes/stores/getters.js31
-rw-r--r--app/assets/javascripts/notes/stores/index.js23
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js14
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js151
-rw-r--r--app/assets/javascripts/notes/stores/utils.js31
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue2
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue93
-rw-r--r--app/assets/stylesheets/framework/common.scss1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss8
-rw-r--r--app/assets/stylesheets/framework/variables.scss7
-rw-r--r--app/assets/stylesheets/new_nav.scss10
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/issues.scss4
-rw-r--r--app/assets/stylesheets/pages/members.scss8
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss12
-rw-r--r--app/assets/stylesheets/pages/note_form.scss8
-rw-r--r--app/assets/stylesheets/pages/notes.scss22
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss217
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/concerns/notes_actions.rb56
-rw-r--r--app/controllers/passwords_controller.rb10
-rw-r--r--app/controllers/profiles/passwords_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb22
-rw-r--r--app/finders/issuable_finder.rb29
-rw-r--r--app/finders/issues_finder.rb38
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/application_settings_helper.rb16
-rw-r--r--app/helpers/form_helper.rb4
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb21
-rw-r--r--app/helpers/issues_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb8
-rw-r--r--app/helpers/projects_helper.rb2
-rw-r--r--app/helpers/system_note_helper.rb8
-rw-r--r--app/models/application_setting.rb28
-rw-r--r--app/models/award_emoji.rb7
-rw-r--r--app/models/commit.rb22
-rw-r--r--app/models/concerns/noteable.rb6
-rw-r--r--app/models/discussion.rb4
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/key.rb31
-rw-r--r--app/models/merge_request.rb2
-rw-r--r--app/models/note.rb22
-rw-r--r--app/models/repository.rb2
-rw-r--r--app/models/user.rb2
-rw-r--r--app/models/wiki_page.rb2
-rw-r--r--app/serializers/award_emoji_entity.rb4
-rw-r--r--app/serializers/discussion_entity.rb10
-rw-r--r--app/serializers/discussion_serializer.rb3
-rw-r--r--app/serializers/issuable_entity.rb2
-rw-r--r--app/serializers/issue_entity.rb20
-rw-r--r--app/serializers/note_attachment_entity.rb5
-rw-r--r--app/serializers/note_entity.rb60
-rw-r--r--app/serializers/note_serializer.rb3
-rw-r--r--app/serializers/note_user_entity.rb3
-rw-r--r--app/serializers/user_serializer.rb3
-rw-r--r--app/services/issuable_base_service.rb16
-rw-r--r--app/services/issues/update_service.rb13
-rw-r--r--app/services/projects/after_import_service.rb9
-rw-r--r--app/services/quick_actions/interpret_service.rb18
-rw-r--r--app/validators/key_restriction_validator.rb29
-rw-r--r--app/views/admin/application_settings/_form.html.haml23
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml6
-rw-r--r--app/views/discussions/_headline.html.haml2
-rw-r--r--app/views/groups/issues.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/layouts/header/_new.html.haml8
-rw-r--r--app/views/layouts/nav/_profile.html.haml2
-rw-r--r--app/views/profiles/keys/_key.html.haml8
-rw-r--r--app/views/profiles/keys/_key_details.html.haml1
-rw-r--r--app/views/profiles/preferences/show.html.haml20
-rw-r--r--app/views/projects/_md_preview.html.haml5
-rw-r--r--app/views/projects/issues/_discussion.html.haml14
-rw-r--r--app/views/projects/issues/show.html.haml13
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml4
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml4
-rw-r--r--changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml5
-rw-r--r--changelogs/unreleased/35686-unescape-wiki-title.yml5
-rw-r--r--changelogs/unreleased/36061-mr-ref-instrument.yml5
-rw-r--r--changelogs/unreleased/37194-fix-mr-widget-merge-button-dropdown-caret.yml5
-rw-r--r--changelogs/unreleased/37202-revert-changes-to-signing-enabled.yml5
-rw-r--r--changelogs/unreleased/add_message_to_the_404_page.yml5
-rw-r--r--changelogs/unreleased/bvl-validate-po-files.yml4
-rw-r--r--changelogs/unreleased/fly-out-nav-hiding-fix.yml5
-rw-r--r--changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml5
-rw-r--r--changelogs/unreleased/issue_36820.yml5
-rw-r--r--changelogs/unreleased/move-action.yml4
-rw-r--r--changelogs/unreleased/rouge-2-2-1.yml5
-rw-r--r--changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml5
-rw-r--r--config/initializers/8_metrics.rb1
-rw-r--r--config/initializers/fast_gettext.rb5
-rw-r--r--config/routes/project.rb1
-rw-r--r--config/webpack.config.js2
-rw-r--r--db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb29
-rw-r--r--db/schema.rb4
-rw-r--r--doc/api/settings.md16
-rw-r--r--doc/ci/examples/README.md5
-rw-r--r--doc/ci/examples/code_climate.md6
-rw-r--r--doc/development/i18n_guide.md41
-rw-r--r--doc/security/README.md1
-rw-r--r--doc/security/img/ssh_keys_restrictions_settings.pngbin0 -> 68496 bytes
-rw-r--r--doc/security/ssh_keys_restrictions.md19
-rw-r--r--doc/user/project/import/index.md1
-rw-r--r--doc/user/project/import/perforce.md50
-rw-r--r--doc/user/project/quick_actions.md1
-rw-r--r--features/steps/explore/projects.rb4
-rw-r--r--features/steps/group/milestones.rb4
-rw-r--r--features/steps/project/active_tab.rb16
-rw-r--r--features/steps/project/fork.rb2
-rw-r--r--features/steps/project/issues/issues.rb3
-rw-r--r--features/steps/project/issues/milestones.rb4
-rw-r--r--features/steps/project/merge_requests.rb2
-rw-r--r--features/steps/project/pages.rb4
-rw-r--r--features/steps/project/project_milestone.rb2
-rw-r--r--features/steps/project/redirects.rb2
-rw-r--r--features/steps/project/snippets.rb2
-rw-r--r--features/steps/shared/active_tab.rb8
-rw-r--r--features/steps/shared/note.rb7
-rw-r--r--features/steps/shared/project_tab.rb4
-rw-r--r--lib/api/access_requests.rb2
-rw-r--r--lib/api/award_emoji.rb2
-rw-r--r--lib/api/boards.rb2
-rw-r--r--lib/api/commit_statuses.rb2
-rw-r--r--lib/api/deploy_keys.rb2
-rw-r--r--lib/api/deployments.rb2
-rw-r--r--lib/api/entities.rb4
-rw-r--r--lib/api/environments.rb2
-rw-r--r--lib/api/events.rb2
-rw-r--r--lib/api/group_milestones.rb2
-rw-r--r--lib/api/group_variables.rb2
-rw-r--r--lib/api/groups.rb2
-rw-r--r--lib/api/issues.rb4
-rw-r--r--lib/api/jobs.rb2
-rw-r--r--lib/api/labels.rb2
-rw-r--r--lib/api/members.rb2
-rw-r--r--lib/api/merge_request_diffs.rb2
-rw-r--r--lib/api/merge_requests.rb2
-rw-r--r--lib/api/notes.rb2
-rw-r--r--lib/api/notification_settings.rb2
-rw-r--r--lib/api/pipeline_schedules.rb2
-rw-r--r--lib/api/pipelines.rb2
-rw-r--r--lib/api/project_hooks.rb2
-rw-r--r--lib/api/project_milestones.rb2
-rw-r--r--lib/api/project_snippets.rb2
-rw-r--r--lib/api/projects.rb4
-rw-r--r--lib/api/repositories.rb2
-rw-r--r--lib/api/runners.rb2
-rw-r--r--lib/api/services.rb4
-rw-r--r--lib/api/settings.rb7
-rw-r--r--lib/api/subscriptions.rb2
-rw-r--r--lib/api/todos.rb2
-rw-r--r--lib/api/triggers.rb2
-rw-r--r--lib/api/variables.rb2
-rw-r--r--lib/gitlab/auth.rb4
-rw-r--r--lib/gitlab/git/repository.rb10
-rw-r--r--lib/gitlab/git_access.rb9
-rw-r--r--lib/gitlab/i18n/metadata_entry.rb27
-rw-r--r--lib/gitlab/i18n/po_linter.rb216
-rw-r--r--lib/gitlab/i18n/translation_entry.rb92
-rw-r--r--lib/gitlab/key_fingerprint.rb48
-rw-r--r--lib/gitlab/sentry.rb2
-rw-r--r--lib/gitlab/ssh_public_key.rb71
-rw-r--r--lib/gitlab/utils.rb4
-rw-r--r--lib/gitlab/workhorse.rb5
-rw-r--r--lib/tasks/gettext.rake40
-rw-r--r--locale/en/gitlab.po48
-rw-r--r--locale/gitlab.pot1
-rw-r--r--locale/ja/gitlab.po2
-rw-r--r--locale/ko/gitlab.po2
-rw-r--r--locale/zh_CN/gitlab.po2
-rw-r--r--locale/zh_HK/gitlab.po2
-rw-r--r--locale/zh_TW/gitlab.po10
-rw-r--r--package.json1
-rw-r--r--public/404.html3
-rwxr-xr-xscripts/static-analysis3
-rw-r--r--spec/controllers/application_controller_spec.rb13
-rw-r--r--spec/controllers/passwords_controller_spec.rb8
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb15
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb17
-rw-r--r--spec/factories/keys.rb49
-rw-r--r--spec/features/admin/admin_active_tab_spec.rb8
-rw-r--r--spec/features/admin/admin_hooks_spec.rb4
-rw-r--r--spec/features/admin/admin_settings_spec.rb16
-rw-r--r--spec/features/boards/boards_spec.rb4
-rw-r--r--spec/features/dashboard/active_tab_spec.rb25
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb2
-rw-r--r--spec/features/dashboard/shortcuts_spec.rb2
-rw-r--r--spec/features/groups/group_name_toggle_spec.rb51
-rw-r--r--spec/features/groups/group_settings_spec.rb4
-rw-r--r--spec/features/groups/merge_requests_spec.rb2
-rw-r--r--spec/features/groups_spec.rb2
-rw-r--r--spec/features/issues/award_emoji_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb538
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb2
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb44
-rw-r--r--spec/features/issues/markdown_toolbar_spec.rb20
-rw-r--r--spec/features/issues/note_polling_spec.rb37
-rw-r--r--spec/features/issues/user_uses_slash_commands_spec.rb109
-rw-r--r--spec/features/issues_spec.rb44
-rw-r--r--spec/features/merge_requests/create_new_mr_spec.rb12
-rw-r--r--spec/features/merge_requests/diff_notes_avatars_spec.rb2
-rw-r--r--spec/features/merge_requests/diffs_spec.rb2
-rw-r--r--spec/features/merge_requests/mini_pipeline_graph_spec.rb4
-rw-r--r--spec/features/merge_requests/user_posts_diff_notes_spec.rb2
-rw-r--r--spec/features/participants_autocomplete_spec.rb14
-rw-r--r--spec/features/profiles/account_spec.rb4
-rw-r--r--spec/features/profiles/keys_spec.rb17
-rw-r--r--spec/features/profiles/password_spec.rb4
-rw-r--r--spec/features/projects/guest_navigation_menu_spec.rb4
-rw-r--r--spec/features/projects/issuable_counts_caching_spec.rb132
-rw-r--r--spec/features/projects/members/user_requests_access_spec.rb5
-rw-r--r--spec/features/projects/project_settings_spec.rb14
-rw-r--r--spec/features/projects/sub_group_issuables_spec.rb2
-rw-r--r--spec/features/reportable_note/commit_spec.rb4
-rw-r--r--spec/features/reportable_note/issue_spec.rb2
-rw-r--r--spec/features/reportable_note/merge_request_spec.rb4
-rw-r--r--spec/features/reportable_note/snippets_spec.rb2
-rw-r--r--spec/features/search_spec.rb2
-rw-r--r--spec/features/task_lists_spec.rb11
-rw-r--r--spec/features/uploads/user_uploads_file_to_note_spec.rb1
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request.json2
-rw-r--r--spec/fixtures/fuzzy.po27
-rw-r--r--spec/fixtures/invalid.po25
-rw-r--r--spec/fixtures/missing_metadata.po4
-rw-r--r--spec/fixtures/missing_plurals.po22
-rw-r--r--spec/fixtures/multiple_plurals.po26
-rw-r--r--spec/fixtures/newlines.po48
-rw-r--r--spec/fixtures/unescaped_chars.po21
-rw-r--r--spec/fixtures/valid.po1136
-rw-r--r--spec/helpers/issuables_helper_spec.rb106
-rw-r--r--spec/javascripts/awards_handler_spec.js7
-rw-r--r--spec/javascripts/behaviors/quick_submit_spec.js190
-rw-r--r--spec/javascripts/fixtures/merge_requests.rb5
-rw-r--r--spec/javascripts/fly_out_nav_spec.js27
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js6
-rw-r--r--spec/javascripts/issue_show/components/fields/description_spec.js4
-rw-r--r--spec/javascripts/issue_show/components/fields/project_move_spec.js2
-rw-r--r--spec/javascripts/issue_show/components/form_spec.js6
-rw-r--r--spec/javascripts/notes/components/issue_comment_form_spec.js134
-rw-r--r--spec/javascripts/notes/components/issue_discussion_spec.js50
-rw-r--r--spec/javascripts/notes/components/issue_note_actions_spec.js91
-rw-r--r--spec/javascripts/notes/components/issue_note_app_spec.js255
-rw-r--r--spec/javascripts/notes/components/issue_note_attachment_spec.js23
-rw-r--r--spec/javascripts/notes/components/issue_note_awards_list_spec.js56
-rw-r--r--spec/javascripts/notes/components/issue_note_body_spec.js46
-rw-r--r--spec/javascripts/notes/components/issue_note_edited_text_spec.js47
-rw-r--r--spec/javascripts/notes/components/issue_note_form_spec.js112
-rw-r--r--spec/javascripts/notes/components/issue_note_header_spec.js94
-rw-r--r--spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js37
-rw-r--r--spec/javascripts/notes/components/issue_note_spec.js44
-rw-r--r--spec/javascripts/notes/components/issue_placeholder_note_spec.js39
-rw-r--r--spec/javascripts/notes/components/issue_placeholder_system_note_spec.js24
-rw-r--r--spec/javascripts/notes/components/issue_system_note_spec.js53
-rw-r--r--spec/javascripts/notes/mock_data.js449
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js62
-rw-r--r--spec/javascripts/notes/stores/getters_spec.js58
-rw-r--r--spec/javascripts/notes/stores/helpers.js37
-rw-r--r--spec/javascripts/notes/stores/mutation_spec.js207
-rw-r--r--spec/javascripts/notes_spec.js14
-rw-r--r--spec/javascripts/shortcuts_issuable_spec.js124
-rw-r--r--spec/javascripts/shortcuts_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js20
-rw-r--r--spec/javascripts/vue_shared/components/markdown/field_spec.js7
-rw-r--r--spec/javascripts/zen_mode_spec.js2
-rw-r--r--spec/lib/gitlab/auth_spec.rb10
-rw-r--r--spec/lib/gitlab/git_access_spec.rb38
-rw-r--r--spec/lib/gitlab/i18n/metadata_entry_spec.rb51
-rw-r--r--spec/lib/gitlab/i18n/po_linter_spec.rb337
-rw-r--r--spec/lib/gitlab/i18n/translation_entry_spec.rb203
-rw-r--r--spec/lib/gitlab/key_fingerprint_spec.rb82
-rw-r--r--spec/lib/gitlab/sentry_spec.rb13
-rw-r--r--spec/lib/gitlab/ssh_public_key_spec.rb136
-rw-r--r--spec/lib/gitlab/utils_spec.rb8
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb17
-rw-r--r--spec/mailers/notify_spec.rb95
-rw-r--r--spec/models/application_setting_spec.rb59
-rw-r--r--spec/models/award_emoji_spec.rb36
-rw-r--r--spec/models/commit_spec.rb61
-rw-r--r--spec/models/key_spec.rb55
-rw-r--r--spec/models/merge_request_spec.rb2
-rw-r--r--spec/models/repository_spec.rb5
-rw-r--r--spec/models/wiki_page_spec.rb6
-rw-r--r--spec/requests/api/commits_spec.rb2
-rw-r--r--spec/requests/api/settings_spec.rb14
-rw-r--r--spec/requests/api/v3/commits_spec.rb2
-rw-r--r--spec/serializers/note_entity_spec.rb51
-rw-r--r--spec/services/issues/update_service_spec.rb20
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb10
-rw-r--r--spec/support/features/discussion_comments_shared_example.rb27
-rw-r--r--spec/support/features/issuable_slash_commands_shared_examples.rb24
-rw-r--r--spec/support/features/reportable_note_shared_examples.rb9
-rw-r--r--spec/support/notify_shared_examples.rb5
-rw-r--r--yarn.lock4
339 files changed, 9459 insertions, 1844 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 7b52f5e5178..93d4c1ef06f 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.35.0
+0.36.0
diff --git a/Gemfile b/Gemfile
index a05747e9ef5..61c941ae449 100644
--- a/Gemfile
+++ b/Gemfile
@@ -349,6 +349,8 @@ group :development, :test do
gem 'activerecord_sane_schema_dumper', '0.2'
gem 'stackprof', '~> 0.2.10', require: false
+
+ gem 'simple_po_parser', '~> 1.1.2', require: false
end
group :test do
diff --git a/Gemfile.lock b/Gemfile.lock
index 8634a9e8822..cba30e856ed 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -723,7 +723,7 @@ GEM
retriable (1.4.1)
rinku (2.0.0)
rotp (2.1.2)
- rouge (2.2.0)
+ rouge (2.2.1)
rqrcode (0.7.0)
chunky_png
rqrcode-rails3 (0.1.7)
@@ -833,6 +833,7 @@ GEM
faraday (~> 0.9)
jwt (~> 1.5)
multi_json (~> 1.10)
+ simple_po_parser (1.1.2)
simplecov (0.14.1)
docile (~> 1.1.0)
json (>= 1.8, < 3)
@@ -1145,6 +1146,7 @@ DEPENDENCIES
sidekiq (~> 5.0)
sidekiq-cron (~> 0.6.0)
sidekiq-limit_fetch (~> 3.4)
+ simple_po_parser (~> 1.1.2)
simplecov (~> 0.14.0)
slack-notifier (~> 1.5.1)
spinach-rails (~> 0.2.1)
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index cfab6c40b34..4d2d4db7c0e 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -2,17 +2,17 @@
import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
- function Autosave(field, key) {
+ function Autosave(field, key, resource) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-
+ this.resource = resource;
if (key.join != null) {
- key = key.join("/");
+ key = key.join('/');
}
- this.key = "autosave/" + key;
- this.field.data("autosave", this);
+ this.key = 'autosave/' + key;
+ this.field.data('autosave', this);
this.restore();
- this.field.on("input", (function(_this) {
+ this.field.on('input', (function(_this) {
return function() {
return _this.save();
};
@@ -29,7 +29,17 @@ window.Autosave = (function() {
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
- return this.field.trigger("input");
+ if (!this.resource && this.resource !== 'issue') {
+ this.field.trigger('input');
+ } else {
+ // v-model does not update with jQuery trigger
+ // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
+ const event = new Event('change', { bubbles: true, cancelable: false });
+ const field = this.field.get(0);
+ if (field) {
+ field.dispatchEvent(event);
+ }
+ }
};
Autosave.prototype.save = function() {
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 097f79a250a..22fa1f2a609 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -109,6 +109,7 @@ class AwardsHandler {
}
$thumbsBtn.toggleClass('disabled', $userAuthored);
+ $thumbsBtn.prop('disabled', $userAuthored);
}
// Create the emoji menu with the first category of emojis.
@@ -234,14 +235,33 @@ class AwardsHandler {
}
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
+ const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
+
+ if (gl.utils.isInIssuePage() && !isMainAwardsBlock) {
+ const id = votesBlock.attr('id').replace('note_', '');
+
+ $('.emoji-menu').removeClass('is-visible');
+ $('.js-add-award.is-active').removeClass('is-active');
+ const toggleAwardEvent = new CustomEvent('toggleAward', {
+ detail: {
+ awardName: emoji,
+ noteId: id,
+ },
+ });
+
+ document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
+ }
+
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
+
$('.emoji-menu').removeClass('is-visible');
- $('.js-add-award.is-active').removeClass('is-active');
+ return $('.js-add-award.is-active').removeClass('is-active');
}
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
@@ -268,6 +288,14 @@ class AwardsHandler {
}
getVotesBlock() {
+ if (gl.utils.isInIssuePage()) {
+ const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
+
+ if ($el.length) {
+ return $el;
+ }
+ }
+
const currentBlock = $('.js-awards-block.current');
let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) {
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index bc693616460..79702c54852 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]);
- $submitButton.disable();
+
+ if (!gl.utils.isInIssuePage()) {
+ $submitButton.disable();
+ }
}
});
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index c70a17104fd..3dec4de06ec 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown';
path = page.split(':');
shortcut_handler = null;
- $('.js-gfm-input').each((i, el) => {
+ $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
gfm.setup($(el), {
@@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown';
shortcut_handler = new ShortcutsIssuable();
new ZenMode();
initIssuableSidebar();
- initNotes();
break;
case 'dashboard:milestones:index':
new ProjectSelect();
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 6d19a6d9b3a..975903159be 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -128,7 +128,7 @@ window.DropzoneInput = (function() {
// removeAllFiles(true) stops uploading files (if any)
// and remove them from dropzone files queue.
$cancelButton.on('click', (e) => {
- const target = e.target.closest('form').querySelector('.div-dropzone');
+ const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone');
e.preventDefault();
e.stopPropagation();
@@ -140,7 +140,7 @@ window.DropzoneInput = (function() {
// and add that files to the dropzone files queue again.
// addFile() adds file to dropzone files queue and upload it.
$retryLink.on('click', (e) => {
- const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone'));
+ const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone'));
const failedFiles = dropzoneInstance.files;
e.preventDefault();
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index 81697af189b..063155a167a 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -12,6 +12,7 @@ let sidebar;
export const mousePos = [];
export const setSidebar = (el) => { sidebar = el; };
+export const getOpenMenu = () => currentOpenMenu;
export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
@@ -141,6 +142,14 @@ export const documentMouseMove = (e) => {
if (mousePos.length > 6) mousePos.shift();
};
+export const subItemsMouseLeave = (relatedTarget) => {
+ clearTimeout(timeoutId);
+
+ if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) {
+ hideMenu(currentOpenMenu);
+ }
+};
+
export default () => {
sidebar = document.querySelector('.nav-sidebar');
@@ -162,10 +171,7 @@ export default () => {
const subItems = el.querySelector('.sidebar-sub-level-items');
if (subItems) {
- subItems.addEventListener('mouseleave', () => {
- clearTimeout(timeoutId);
- hideMenu(currentOpenMenu);
- });
+ subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget));
}
el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 2bee4fb045a..7c4f4da6127 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -42,7 +42,7 @@ class Issue {
initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.';
- return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => {
+ return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => {
var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
@@ -66,12 +66,11 @@ class Issue {
const projectIssuesCounter = $('.issue_counter');
if ('id' in data) {
- $(document).trigger('issuable:change');
-
const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed);
+ $(document).trigger('issuable:change', isClosed);
this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
@@ -121,7 +120,7 @@ class Issue {
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
- if (noteText.trim().length > 0) {
+ if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index efae112923d..eaaafd4c149 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -80,11 +80,11 @@ export default {
type: Boolean,
required: true,
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -96,7 +96,7 @@ export default {
type: String,
required: true,
},
- projectsAutocompleteUrl: {
+ projectsAutocompletePath: {
type: String,
required: true,
},
@@ -242,11 +242,11 @@ export default {
:can-move="canMove"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
- :markdown-docs="markdownDocs"
- :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs-path="markdownDocsPath"
+ :markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
- :projects-autocomplete-url="projectsAutocompleteUrl"
+ :projects-autocomplete-path="projectsAutocompletePath"
/>
<div v-else>
<title-component
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 27b1b814f9a..dc902eefc5f 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -10,11 +10,11 @@
type: Object,
required: true,
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -36,8 +36,8 @@
Description
</label>
<markdown-field
- :markdown-preview-url="markdownPreviewUrl"
- :markdown-docs="markdownDocs">
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath">
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
index 7bf2be8b28a..e514bebc5f6 100644
--- a/app/assets/javascripts/issue_show/components/fields/project_move.vue
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -10,7 +10,7 @@
type: Object,
required: true,
},
- projectsAutocompleteUrl: {
+ projectsAutocompletePath: {
type: String,
required: true,
},
@@ -20,7 +20,7 @@
$moveDropdown.select2({
ajax: {
- url: this.projectsAutocompleteUrl,
+ url: this.projectsAutocompletePath,
quietMillis: 125,
data(term, page, context) {
return {
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 76ec3dc9a5d..d9b53bc55cf 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -26,11 +26,11 @@
required: false,
default: () => [],
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -42,7 +42,7 @@
type: String,
required: true,
},
- projectsAutocompleteUrl: {
+ projectsAutocompletePath: {
type: String,
required: true,
},
@@ -89,14 +89,14 @@
</div>
<description-field
:form-state="formState"
- :markdown-preview-url="markdownPreviewUrl"
- :markdown-docs="markdownDocs" />
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath" />
<confidential-checkbox
:form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
- :projects-autocomplete-url="projectsAutocompleteUrl" />
+ :projects-autocomplete-path="projectsAutocompletePath" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index ad8cb6465e2..60b69b300fd 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => {
initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential,
- markdownPreviewUrl: this.markdownPreviewUrl,
- markdownDocs: this.markdownDocs,
+ markdownPreviewPath: this.markdownPreviewPath,
+ markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
- projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ projectsAutocompletePath: this.projectsAutocompletePath,
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index b8f4f4eaba3..b8bebe1894f 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -27,6 +27,13 @@
}
};
+ w.gl.utils.isInIssuePage = () => {
+ const page = gl.utils.getPagePath(1);
+ const action = gl.utils.getPagePath(2);
+
+ return page === 'issues' && action === 'show';
+ };
+
w.gl.utils.ajaxGet = function(url) {
return $.ajax({
type: "GET",
@@ -167,11 +174,12 @@
};
gl.utils.scrollToElement = function($el) {
- var top = $el.offset().top;
- gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height();
+ const top = $el.offset().top;
+ const mrTabsHeight = $('.merge-request-tabs').height() || 0;
+ const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({
- scrollTop: top - (gl.mrTabsHeight)
+ scrollTop: top - mrTabsHeight - headerHeight,
}, 200);
};
diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue
new file mode 100644
index 00000000000..16f4e22aa9b
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_comment_form.vue
@@ -0,0 +1,347 @@
+<script>
+ /* global Flash, Autosave */
+ import { mapActions, mapGetters } from 'vuex';
+ import _ from 'underscore';
+ import '../../autosave';
+ import TaskList from '../../task_list';
+ import * as constants from '../constants';
+ import eventHub from '../event_hub';
+ import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
+ import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
+ import markdownField from '../../vue_shared/components/markdown/field.vue';
+ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+
+ export default {
+ name: 'issueCommentForm',
+ data() {
+ return {
+ note: '',
+ noteType: constants.COMMENT,
+ // Can't use mapGetters,
+ // this needs to be in the data object because it belongs to the state
+ issueState: this.$store.getters.getIssueData.state,
+ isSubmitting: false,
+ isSubmitButtonDisabled: true,
+ };
+ },
+ components: {
+ confidentialIssue,
+ issueNoteSignedOutWidget,
+ markdownField,
+ userAvatarLink,
+ },
+ watch: {
+ note(newNote) {
+ this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
+ },
+ isSubmitting(newValue) {
+ this.setIsSubmitButtonDisabled(this.note, newValue);
+ },
+ },
+ computed: {
+ ...mapGetters([
+ 'getCurrentUserLastNote',
+ 'getUserData',
+ 'getIssueData',
+ 'getNotesData',
+ ]),
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ commentButtonTitle() {
+ return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
+ },
+ isIssueOpen() {
+ return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
+ },
+ issueActionButtonTitle() {
+ if (this.note.length) {
+ const actionText = this.isIssueOpen ? 'close' : 'reopen';
+
+ return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`;
+ }
+
+ return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
+ },
+ actionButtonClassNames() {
+ return {
+ 'btn-reopen': !this.isIssueOpen,
+ 'btn-close': this.isIssueOpen,
+ 'js-note-target-close': this.isIssueOpen,
+ 'js-note-target-reopen': !this.isIssueOpen,
+ };
+ },
+ markdownDocsPath() {
+ return this.getNotesData.markdownDocsPath;
+ },
+ quickActionsDocsPath() {
+ return this.getNotesData.quickActionsDocsPath;
+ },
+ markdownPreviewPath() {
+ return this.getIssueData.preview_note_path;
+ },
+ author() {
+ return this.getUserData;
+ },
+ canUpdateIssue() {
+ return this.getIssueData.current_user.can_update;
+ },
+ endpoint() {
+ return this.getIssueData.create_note_path;
+ },
+ isConfidentialIssue() {
+ return this.getIssueData.confidential;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'saveNote',
+ 'removePlaceholderNotes',
+ ]),
+ setIsSubmitButtonDisabled(note, isSubmitting) {
+ if (!_.isEmpty(note) && !isSubmitting) {
+ this.isSubmitButtonDisabled = false;
+ } else {
+ this.isSubmitButtonDisabled = true;
+ }
+ },
+ handleSave(withIssueAction) {
+ if (this.note.length) {
+ const noteData = {
+ endpoint: this.endpoint,
+ flashContainer: this.$el,
+ data: {
+ note: {
+ noteable_type: constants.NOTEABLE_TYPE,
+ noteable_id: this.getIssueData.id,
+ note: this.note,
+ },
+ },
+ };
+
+ if (this.noteType === constants.DISCUSSION) {
+ noteData.data.note.type = constants.DISCUSSION_NOTE;
+ }
+ this.isSubmitting = true;
+ this.note = ''; // Empty textarea while being requested. Repopulate in catch
+
+ this.saveNote(noteData)
+ .then((res) => {
+ this.isSubmitting = false;
+ if (res.errors) {
+ if (res.errors.commands_only) {
+ this.discard();
+ } else {
+ Flash(
+ 'Something went wrong while adding your comment. Please try again.',
+ 'alert',
+ $(this.$refs.commentForm),
+ );
+ }
+ } else {
+ this.discard();
+ }
+
+ if (withIssueAction) {
+ this.toggleIssueState();
+ }
+ })
+ .catch(() => {
+ this.isSubmitting = false;
+ this.discard(false);
+ const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
+ Flash(msg, 'alert', $(this.$el));
+ this.note = noteData.data.note.note; // Restore textarea content.
+ this.removePlaceholderNotes();
+ });
+ } else {
+ this.toggleIssueState();
+ }
+ },
+ toggleIssueState() {
+ this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED;
+
+ // This is out of scope for the Notes Vue component.
+ // It was the shortest path to update the issue state and relevant places.
+ const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close';
+ $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click');
+ },
+ discard(shouldClear = true) {
+ // `blur` is needed to clear slash commands autocomplete cache if event fired.
+ // `focus` is needed to remain cursor in the textarea.
+ this.$refs.textarea.blur();
+ this.$refs.textarea.focus();
+
+ if (shouldClear) {
+ this.note = '';
+ }
+
+ // reset autostave
+ this.autosave.reset();
+ },
+ setNoteType(type) {
+ this.noteType = type;
+ },
+ editCurrentUserLastNote() {
+ if (this.note === '') {
+ const lastNote = this.getCurrentUserLastNote;
+
+ if (lastNote) {
+ eventHub.$emit('enterEditMode', {
+ noteId: lastNote.id,
+ });
+ }
+ }
+ },
+ initAutoSave() {
+ if (this.isLoggedIn) {
+ this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue');
+ }
+ },
+ initTaskList() {
+ return new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes',
+ });
+ },
+ },
+ mounted() {
+ // jQuery is needed here because it is a custom event being dispatched with jQuery.
+ $(document).on('issuable:change', (e, isClosed) => {
+ this.issueState = isClosed ? constants.CLOSED : constants.REOPENED;
+ });
+
+ this.initAutoSave();
+ this.initTaskList();
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <issue-note-signed-out-widget v-if="!isLoggedIn" />
+ <ul
+ v-else
+ class="notes notes-form timeline">
+ <li class="timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="flash-container error-alert timeline-content"></div>
+ <div class="timeline-icon hidden-xs hidden-sm">
+ <user-avatar-link
+ v-if="author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content timeline-content-form">
+ <form
+ ref="commentForm"
+ class="new-note js-quick-submit common-note-form gfm-form js-main-target-form">
+ <confidentialIssue v-if="isConfidentialIssue" />
+ <div class="error-alert"></div>
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :add-spacing-classes="false"
+ :is-confidential-issue="isConfidentialIssue">
+ <textarea
+ id="note-body"
+ name="note[note]"
+ class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea"
+ data-supports-quick-actions="true"
+ aria-label="Description"
+ v-model="note"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.up="editCurrentUserLastNote()"
+ @keydown.meta.enter="handleSave()">
+ </textarea>
+ </markdown-field>
+ <div class="note-form-actions">
+ <div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
+ <button
+ @click.prevent="handleSave()"
+ :disabled="isSubmitButtonDisabled"
+ class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
+ type="submit">
+ {{commentButtonTitle}}
+ </button>
+ <button
+ :disabled="isSubmitButtonDisabled"
+ name="button"
+ type="button"
+ class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle"
+ data-toggle="dropdown"
+ aria-label="Open comment type dropdown">
+ <i
+ aria-hidden="true"
+ class="fa fa-caret-down toggle-icon">
+ </i>
+ </button>
+
+ <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
+ <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
+ <button
+ type="button"
+ class="btn btn-transparent"
+ @click.prevent="setNoteType('comment')">
+ <i
+ aria-hidden="true"
+ class="fa fa-check icon">
+ </i>
+ <div class="description">
+ <strong>Comment</strong>
+ <p>
+ Add a general comment to this issue.
+ </p>
+ </div>
+ </button>
+ </li>
+ <li class="divider droplab-item-ignore"></li>
+ <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
+ <button
+ type="button"
+ class="btn btn-transparent"
+ @click.prevent="setNoteType('discussion')">
+ <i
+ aria-hidden="true"
+ class="fa fa-check icon">
+ </i>
+ <div class="description">
+ <strong>Start discussion</strong>
+ <p>
+ Discuss a specific suggestion or question.
+ </p>
+ </div>
+ </button>
+ </li>
+ </ul>
+ </div>
+ <button
+ type="button"
+ @click="handleSave(true)"
+ v-if="canUpdateIssue"
+ :class="actionButtonClassNames"
+ class="btn btn-comment btn-comment-and-close">
+ {{issueActionButtonTitle}}
+ </button>
+ <button
+ type="button"
+ v-if="note.length"
+ @click="discard"
+ class="btn btn-cancel js-note-discard">
+ Discard draft
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue
new file mode 100644
index 00000000000..b131ef4b182
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_discussion.vue
@@ -0,0 +1,232 @@
+<script>
+ /* global Flash */
+ import { mapActions, mapGetters } from 'vuex';
+ import { SYSTEM_NOTE } from '../constants';
+ import issueNote from './issue_note.vue';
+ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+ import issueNoteHeader from './issue_note_header.vue';
+ import issueNoteActions from './issue_note_actions.vue';
+ import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
+ import issueNoteEditedText from './issue_note_edited_text.vue';
+ import issueNoteForm from './issue_note_form.vue';
+ import placeholderNote from './issue_placeholder_note.vue';
+ import placeholderSystemNote from './issue_placeholder_system_note.vue';
+ import autosave from '../mixins/autosave';
+
+ export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isReplying: false,
+ };
+ },
+ components: {
+ issueNote,
+ userAvatarLink,
+ issueNoteHeader,
+ issueNoteActions,
+ issueNoteSignedOutWidget,
+ issueNoteEditedText,
+ issueNoteForm,
+ placeholderNote,
+ placeholderSystemNote,
+ },
+ mixins: [
+ autosave,
+ ],
+ computed: {
+ ...mapGetters([
+ 'getIssueData',
+ ]),
+ discussion() {
+ return this.note.notes[0];
+ },
+ author() {
+ return this.discussion.author;
+ },
+ canReply() {
+ return this.getIssueData.current_user.can_create_note;
+ },
+ newNotePath() {
+ return this.getIssueData.create_note_path;
+ },
+ lastUpdatedBy() {
+ const { notes } = this.note;
+
+ if (notes.length > 1) {
+ return notes[notes.length - 1].author;
+ }
+
+ return null;
+ },
+ lastUpdatedAt() {
+ const { notes } = this.note;
+
+ if (notes.length > 1) {
+ return notes[notes.length - 1].created_at;
+ }
+
+ return null;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'saveNote',
+ 'toggleDiscussion',
+ 'removePlaceholderNotes',
+ ]),
+ componentName(note) {
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === SYSTEM_NOTE) {
+ return placeholderSystemNote;
+ }
+ return placeholderNote;
+ }
+
+ return issueNote;
+ },
+ componentData(note) {
+ return note.isPlaceholderNote ? note.notes[0] : note;
+ },
+ toggleDiscussionHandler() {
+ this.toggleDiscussion({ discussionId: this.note.id });
+ },
+ showReplyForm() {
+ this.isReplying = true;
+ },
+ cancelReplyForm(shouldConfirm) {
+ if (shouldConfirm && this.$refs.noteForm.isDirty) {
+ // eslint-disable-next-line no-alert
+ if (!confirm('Are you sure you want to cancel creating this comment?')) {
+ return;
+ }
+ }
+
+ this.resetAutoSave();
+ this.isReplying = false;
+ },
+ saveReply(noteText, form, callback) {
+ const replyData = {
+ endpoint: this.newNotePath,
+ flashContainer: this.$el,
+ data: {
+ in_reply_to_discussion_id: this.note.reply_id,
+ target_type: 'issue',
+ target_id: this.discussion.noteable_id,
+ note: { note: noteText },
+ },
+ };
+ this.isReplying = false;
+
+ this.saveNote(replyData)
+ .then(() => {
+ this.resetAutoSave();
+ callback();
+ })
+ .catch((err) => {
+ this.removePlaceholderNotes();
+ this.isReplying = true;
+ this.$nextTick(() => {
+ const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
+ Flash(msg, 'alert', $(this.$el));
+ this.$refs.noteForm.note = noteText;
+ callback(err);
+ });
+ });
+ },
+ },
+ mounted() {
+ if (this.isReplying) {
+ this.initAutoSave();
+ }
+ },
+ updated() {
+ if (this.isReplying) {
+ if (!this.autosave) {
+ this.initAutoSave();
+ } else {
+ this.setAutoSave();
+ }
+ }
+ },
+ };
+</script>
+
+<template>
+ <li class="note note-discussion timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content">
+ <div class="discussion">
+ <div class="discussion-header">
+ <issue-note-header
+ :author="author"
+ :created-at="discussion.created_at"
+ :note-id="discussion.id"
+ :include-toggle="true"
+ @toggleHandler="toggleDiscussionHandler"
+ action-text="started a discussion"
+ class="discussion"
+ />
+ <issue-note-edited-text
+ v-if="lastUpdatedAt"
+ :edited-at="lastUpdatedAt"
+ :edited-by="lastUpdatedBy"
+ action-text="Last updated"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ </div>
+ </div>
+ <div
+ v-if="note.expanded"
+ class="discussion-body">
+ <div class="panel panel-default">
+ <div class="discussion-notes">
+ <ul class="notes">
+ <component
+ v-for="note in note.notes"
+ :is="componentName(note)"
+ :note="componentData(note)"
+ :key="note.id"
+ />
+ </ul>
+ <div
+ :class="{ 'is-replying': isReplying }"
+ class="discussion-reply-holder">
+ <button
+ v-if="canReply && !isReplying"
+ @click="showReplyForm"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ title="Add a reply">Reply...</button>
+ <issue-note-form
+ v-if="isReplying"
+ save-button-title="Comment"
+ :discussion="note"
+ :is-editing="false"
+ @handleFormUpdate="saveReply"
+ @cancelFormEdition="cancelReplyForm"
+ ref="noteForm"
+ />
+ <issue-note-signed-out-widget v-if="!canReply" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue
new file mode 100644
index 00000000000..3483f6c7538
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note.vue
@@ -0,0 +1,186 @@
+<script>
+ /* global Flash */
+
+ import { mapGetters, mapActions } from 'vuex';
+ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+ import issueNoteHeader from './issue_note_header.vue';
+ import issueNoteActions from './issue_note_actions.vue';
+ import issueNoteBody from './issue_note_body.vue';
+ import eventHub from '../event_hub';
+
+ export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ isDeleting: false,
+ isRequesting: false,
+ };
+ },
+ components: {
+ userAvatarLink,
+ issueNoteHeader,
+ issueNoteActions,
+ issueNoteBody,
+ },
+ computed: {
+ ...mapGetters([
+ 'targetNoteHash',
+ 'getUserData',
+ ]),
+ author() {
+ return this.note.author;
+ },
+ classNameBindings() {
+ return {
+ 'is-editing': this.isEditing && !this.isRequesting,
+ 'is-requesting being-posted': this.isRequesting,
+ 'disabled-content': this.isDeleting,
+ target: this.targetNoteHash === this.noteAnchorId,
+ };
+ },
+ canReportAsAbuse() {
+ return this.note.report_abuse_path && this.author.id !== this.getUserData.id;
+ },
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'deleteNote',
+ 'updateNote',
+ 'scrollToNoteIfNeeded',
+ ]),
+ editHandler() {
+ this.isEditing = true;
+ },
+ deleteHandler() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Are you sure you want to delete this list?')) {
+ this.isDeleting = true;
+
+ this.deleteNote(this.note)
+ .then(() => {
+ this.isDeleting = false;
+ })
+ .catch(() => {
+ Flash('Something went wrong while deleting your note. Please try again.');
+ this.isDeleting = false;
+ });
+ }
+ },
+ formUpdateHandler(noteText, parentElement, callback) {
+ const data = {
+ endpoint: this.note.path,
+ note: {
+ target_type: 'issue',
+ target_id: this.note.noteable_id,
+ note: { note: noteText },
+ },
+ };
+ this.isRequesting = true;
+ this.oldContent = this.note.note_html;
+ this.note.note_html = noteText;
+
+ this.updateNote(data)
+ .then(() => {
+ this.isEditing = false;
+ this.isRequesting = false;
+ $(this.$refs.noteBody.$el).renderGFM();
+ this.$refs.noteBody.resetAutoSave();
+ callback();
+ })
+ .catch(() => {
+ this.isRequesting = false;
+ this.isEditing = true;
+ this.$nextTick(() => {
+ const msg = 'Something went wrong while editing your comment. Please try again.';
+ Flash(msg, 'alert', $(this.$el));
+ this.recoverNoteContent(noteText);
+ callback();
+ });
+ });
+ },
+ formCancelHandler(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ // eslint-disable-next-line no-alert
+ if (!confirm('Are you sure you want to cancel editing this comment?')) return;
+ }
+ this.$refs.noteBody.resetAutoSave();
+ if (this.oldContent) {
+ this.note.note_html = this.oldContent;
+ this.oldContent = null;
+ }
+ this.isEditing = false;
+ },
+ recoverNoteContent(noteText) {
+ // we need to do this to prevent noteForm inconsistent content warning
+ // this is something we intentionally do so we need to recover the content
+ this.note.note = noteText;
+ this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better
+ },
+ },
+ created() {
+ eventHub.$on('enterEditMode', ({ noteId }) => {
+ if (noteId === this.note.id) {
+ this.isEditing = true;
+ this.scrollToNoteIfNeeded($(this.$el));
+ }
+ });
+ },
+ };
+</script>
+
+<template>
+ <li
+ class="note timeline-entry"
+ :id="noteAnchorId"
+ :class="classNameBindings"
+ :data-award-url="note.toggle_award_path">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <issue-note-header
+ :author="author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ action-text="commented"
+ />
+ <issue-note-actions
+ :author-id="author.id"
+ :note-id="note.id"
+ :access-level="note.human_access"
+ :can-edit="note.current_user.can_edit"
+ :can-delete="note.current_user.can_edit"
+ :can-report-as-abuse="canReportAsAbuse"
+ :report-abuse-path="note.report_abuse_path"
+ @handleEdit="editHandler"
+ @handleDelete="deleteHandler"
+ />
+ </div>
+ <issue-note-body
+ :note="note"
+ :can-edit="note.current_user.can_edit"
+ :is-editing="isEditing"
+ @handleFormUpdate="formUpdateHandler"
+ @cancelFormEdition="formCancelHandler"
+ ref="noteBody"
+ />
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_actions.vue b/app/assets/javascripts/notes/components/issue_note_actions.vue
new file mode 100644
index 00000000000..60c172321d1
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_actions.vue
@@ -0,0 +1,167 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
+ import emojiSmile from 'icons/_emoji_smile.svg';
+ import emojiSmiley from 'icons/_emoji_smiley.svg';
+ import editSvg from 'icons/_icon_pencil.svg';
+ import ellipsisSvg from 'icons/_ellipsis_v.svg';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ name: 'issueNoteActions',
+ props: {
+ authorId: {
+ type: Number,
+ required: true,
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ accessLevel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ reportAbusePath: {
+ type: String,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ canDelete: {
+ type: Boolean,
+ required: true,
+ },
+ canReportAsAbuse: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserDataByProp',
+ ]),
+ shouldShowActionsDropdown() {
+ return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
+ },
+ canAddAwardEmoji() {
+ return this.currentUserId;
+ },
+ isAuthoredByCurrentUser() {
+ return this.authorId === this.currentUserId;
+ },
+ currentUserId() {
+ return this.getUserDataByProp('id');
+ },
+ },
+ methods: {
+ onEdit() {
+ this.$emit('handleEdit');
+ },
+ onDelete() {
+ this.$emit('handleDelete');
+ },
+ },
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ this.editSvg = editSvg;
+ this.ellipsisSvg = ellipsisSvg;
+ },
+ };
+</script>
+
+<template>
+ <div class="note-actions">
+ <span
+ v-if="accessLevel"
+ class="note-role">{{accessLevel}}</span>
+ <div
+ v-if="canAddAwardEmoji"
+ class="note-actions-item">
+ <a
+ v-tooltip
+ :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
+ class="note-action-button note-emoji-button js-add-award js-note-emoji"
+ data-position="right"
+ data-placement="bottom"
+ data-container="body"
+ href="#"
+ title="Add reaction">
+ <loading-icon :inline="true" />
+ <span
+ v-html="emojiSmiling"
+ class="link-highlight award-control-icon-neutral">
+ </span>
+ <span
+ v-html="emojiSmiley"
+ class="link-highlight award-control-icon-positive">
+ </span>
+ <span
+ v-html="emojiSmile"
+ class="link-highlight award-control-icon-super-positive">
+ </span>
+ </a>
+ </div>
+ <div
+ v-if="canEdit"
+ class="note-actions-item">
+ <button
+ @click="onEdit"
+ v-tooltip
+ type="button"
+ title="Edit comment"
+ class="note-action-button js-note-edit btn btn-transparent"
+ data-container="body"
+ data-placement="bottom">
+ <span
+ v-html="editSvg"
+ class="link-highlight"></span>
+ </button>
+ </div>
+ <div
+ v-if="shouldShowActionsDropdown"
+ class="dropdown more-actions note-actions-item">
+ <button
+ v-tooltip
+ type="button"
+ title="More actions"
+ class="note-action-button more-actions-toggle btn btn-transparent"
+ data-toggle="dropdown"
+ data-container="body"
+ data-placement="bottom">
+ <span
+ class="icon"
+ v-html="ellipsisSvg"></span>
+ </button>
+ <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
+ <li v-if="canReportAsAbuse">
+ <a :href="reportAbusePath">
+ Report as abuse
+ </a>
+ </li>
+ <li v-if="canEdit">
+ <button
+ @click.prevent="onDelete"
+ class="btn btn-transparent js-note-delete js-note-delete"
+ type="button">
+ <span class="text-danger">
+ Delete comment
+ </span>
+ </button>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_attachment.vue b/app/assets/javascripts/notes/components/issue_note_attachment.vue
new file mode 100644
index 00000000000..7134a3eb47e
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_attachment.vue
@@ -0,0 +1,37 @@
+<script>
+ export default {
+ name: 'issueNoteAttachment',
+ props: {
+ attachment: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="note-attachment">
+ <a
+ v-if="attachment.image"
+ :href="attachment.url"
+ target="_blank"
+ rel="noopener noreferrer">
+ <img
+ :src="attachment.url"
+ class="note-image-attach" />
+ </a>
+ <div class="attachment">
+ <a
+ v-if="attachment.url"
+ :href="attachment.url"
+ target="_blank"
+ rel="noopener noreferrer">
+ <i
+ class="fa fa-paperclip"
+ aria-hidden="true"></i>
+ {{attachment.filename}}
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_awards_list.vue b/app/assets/javascripts/notes/components/issue_note_awards_list.vue
new file mode 100644
index 00000000000..d42e61e3899
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_awards_list.vue
@@ -0,0 +1,228 @@
+<script>
+ /* global Flash */
+
+ import { mapActions, mapGetters } from 'vuex';
+ import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
+ import emojiSmile from 'icons/_emoji_smile.svg';
+ import emojiSmiley from 'icons/_emoji_smiley.svg';
+ import { glEmojiTag } from '../../emoji';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ props: {
+ awards: {
+ type: Array,
+ required: true,
+ },
+ toggleAwardPath: {
+ type: String,
+ required: true,
+ },
+ noteAuthorId: {
+ type: Number,
+ required: true,
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserData',
+ ]),
+ // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
+ // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
+ // This method will group emojis by their name as an Object. See below.
+ // {
+ // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
+ // bar: [ { name: bar, user: user1 } ]
+ // }
+ // We need to do this otherwise we will render the same emoji over and over again.
+ groupedAwards() {
+ const awards = this.awards.reduce((acc, award) => {
+ if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
+ acc[award.name].push(award);
+ } else {
+ Object.assign(acc, { [award.name]: [award] });
+ }
+
+ return acc;
+ }, {});
+
+ const orderedAwards = {};
+ const { thumbsdown, thumbsup } = awards;
+ // Always show thumbsup and thumbsdown first
+ if (thumbsup) {
+ orderedAwards.thumbsup = thumbsup;
+ delete awards.thumbsup;
+ }
+ if (thumbsdown) {
+ orderedAwards.thumbsdown = thumbsdown;
+ delete awards.thumbsdown;
+ }
+
+ return Object.assign({}, orderedAwards, awards);
+ },
+ isAuthoredByMe() {
+ return this.noteAuthorId === this.getUserData.id;
+ },
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'toggleAwardRequest',
+ ]),
+ getAwardHTML(name) {
+ return glEmojiTag(name);
+ },
+ getAwardClassBindings(awardList, awardName) {
+ return {
+ active: this.hasReactionByCurrentUser(awardList),
+ disabled: !this.canInteractWithEmoji(awardList, awardName),
+ };
+ },
+ canInteractWithEmoji(awardList, awardName) {
+ let isAllowed = true;
+ const restrictedEmojis = ['thumbsup', 'thumbsdown'];
+
+ // Users can not add :+1: and :-1: to their own notes
+ if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) {
+ isAllowed = false;
+ }
+
+ return this.getUserData.id && isAllowed;
+ },
+ hasReactionByCurrentUser(awardList) {
+ return awardList.filter(award => award.user.id === this.getUserData.id).length;
+ },
+ awardTitle(awardsList) {
+ const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
+ const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
+ let awardList = awardsList;
+
+ // Filter myself from list if I am awarded.
+ if (hasReactionByCurrentUser) {
+ awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
+ }
+
+ // Get only 9-10 usernames to show in tooltip text.
+ const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
+
+ // Get the remaining list to use in `and x more` text.
+ const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
+
+ // Add myself to the begining of the list so title will start with You.
+ if (hasReactionByCurrentUser) {
+ namesToShow.unshift('You');
+ }
+
+ let title = '';
+
+ // We have 10+ awarded user, join them with comma and add `and x more`.
+ if (remainingAwardList.length) {
+ title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
+ } else if (namesToShow.length > 1) {
+ // Join all names with comma but not the last one, it will be added with and text.
+ title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
+ // If we have more than 2 users we need an extra comma before and text.
+ title += namesToShow.length > 2 ? ',' : '';
+ title += ` and ${namesToShow.slice(-1)}`; // Append and text
+ } else { // We have only 2 users so join them with and.
+ title = namesToShow.join(' and ');
+ }
+
+ return title;
+ },
+ handleAward(awardName) {
+ if (!this.isLoggedIn) {
+ return;
+ }
+
+ let parsedName;
+
+ // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
+ switch (awardName) {
+ case '100':
+ parsedName = 100;
+ break;
+ case '1234':
+ parsedName = 1234;
+ break;
+ default:
+ parsedName = awardName;
+ break;
+ }
+
+ const data = {
+ endpoint: this.toggleAwardPath,
+ noteId: this.noteId,
+ awardName: parsedName,
+ };
+
+ this.toggleAwardRequest(data)
+ .catch(() => Flash('Something went wrong on our end.'));
+ },
+ },
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ },
+ };
+</script>
+
+<template>
+ <div class="note-awards">
+ <div class="awards js-awards-block">
+ <button
+ v-tooltip
+ v-for="(awardList, awardName, index) in groupedAwards"
+ :key="index"
+ :class="getAwardClassBindings(awardList, awardName)"
+ :title="awardTitle(awardList)"
+ @click="handleAward(awardName)"
+ class="btn award-control"
+ data-placement="bottom"
+ type="button">
+ <span v-html="getAwardHTML(awardName)"></span>
+ <span class="award-control-text js-counter">
+ {{awardList.length}}
+ </span>
+ </button>
+ <div
+ v-if="isLoggedIn"
+ class="award-menu-holder">
+ <button
+ v-tooltip
+ :class="{ 'js-user-authored': isAuthoredByMe }"
+ class="award-control btn js-add-award"
+ title="Add reaction"
+ aria-label="Add reaction"
+ data-placement="bottom"
+ type="button">
+ <span
+ v-html="emojiSmiling"
+ class="award-control-icon award-control-icon-neutral">
+ </span>
+ <span
+ v-html="emojiSmiley"
+ class="award-control-icon award-control-icon-positive">
+ </span>
+ <span
+ v-html="emojiSmile"
+ class="award-control-icon award-control-icon-super-positive">
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_body.vue b/app/assets/javascripts/notes/components/issue_note_body.vue
new file mode 100644
index 00000000000..5f9003bfd87
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_body.vue
@@ -0,0 +1,122 @@
+<script>
+ import issueNoteEditedText from './issue_note_edited_text.vue';
+ import issueNoteAwardsList from './issue_note_awards_list.vue';
+ import issueNoteAttachment from './issue_note_attachment.vue';
+ import issueNoteForm from './issue_note_form.vue';
+ import TaskList from '../../task_list';
+ import autosave from '../mixins/autosave';
+
+ export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ mixins: [
+ autosave,
+ ],
+ components: {
+ issueNoteEditedText,
+ issueNoteAwardsList,
+ issueNoteAttachment,
+ issueNoteForm,
+ },
+ computed: {
+ noteBody() {
+ return this.note.note;
+ },
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['note-body']).renderGFM();
+ },
+ initTaskList() {
+ if (this.canEdit) {
+ this.taskList = new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes',
+ });
+ }
+ },
+ handleFormUpdate(note, parentElement, callback) {
+ this.$emit('handleFormUpdate', note, parentElement, callback);
+ },
+ formCancelHandler(shouldConfirm, isDirty) {
+ this.$emit('cancelFormEdition', shouldConfirm, isDirty);
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ this.initTaskList();
+
+ if (this.isEditing) {
+ this.initAutoSave();
+ }
+ },
+ updated() {
+ this.initTaskList();
+ this.renderGFM();
+
+ if (this.isEditing) {
+ if (!this.autosave) {
+ this.initAutoSave();
+ } else {
+ this.setAutoSave();
+ }
+ }
+ },
+ };
+</script>
+
+<template>
+ <div
+ :class="{ 'js-task-list-container': canEdit }"
+ ref="note-body"
+ class="note-body">
+ <div
+ v-html="note.note_html"
+ class="note-text md"></div>
+ <issue-note-form
+ v-if="isEditing"
+ ref="noteForm"
+ @handleFormUpdate="handleFormUpdate"
+ @cancelFormEdition="formCancelHandler"
+ :is-editing="isEditing"
+ :note-body="noteBody"
+ :note-id="note.id"
+ />
+ <textarea
+ v-if="canEdit"
+ v-model="note.note"
+ :data-update-url="note.path"
+ class="hidden js-task-list-field"></textarea>
+ <issue-note-edited-text
+ v-if="note.last_edited_at"
+ :edited-at="note.last_edited_at"
+ :edited-by="note.last_edited_by"
+ action-text="Edited"
+ />
+ <issue-note-awards-list
+ v-if="note.award_emoji.length"
+ :note-id="note.id"
+ :note-author-id="note.author.id"
+ :awards="note.award_emoji"
+ :toggle-award-path="note.toggle_award_path"
+ />
+ <issue-note-attachment
+ v-if="note.attachment"
+ :attachment="note.attachment"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_edited_text.vue b/app/assets/javascripts/notes/components/issue_note_edited_text.vue
new file mode 100644
index 00000000000..49e09f0ecc5
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_edited_text.vue
@@ -0,0 +1,47 @@
+<script>
+ import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+ export default {
+ name: 'editedNoteText',
+ props: {
+ actionText: {
+ type: String,
+ required: true,
+ },
+ editedAt: {
+ type: String,
+ required: true,
+ },
+ editedBy: {
+ type: Object,
+ required: false,
+ },
+ className: {
+ type: String,
+ required: false,
+ default: 'edited-text',
+ },
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ };
+</script>
+
+<template>
+ <div :class="className">
+ {{actionText}}
+ <time-ago-tooltip
+ :time="editedAt"
+ tooltip-placement="bottom"
+ />
+ <template v-if="editedBy">
+ by
+ <a
+ :href="editedBy.path"
+ class="js-vue-author author_link">
+ {{editedBy.name}}
+ </a>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue
new file mode 100644
index 00000000000..626c0f2ce18
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_form.vue
@@ -0,0 +1,166 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import eventHub from '../event_hub';
+ import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
+ import markdownField from '../../vue_shared/components/markdown/field.vue';
+
+ export default {
+ name: 'issueNoteForm',
+ props: {
+ noteBody: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ noteId: {
+ type: Number,
+ required: false,
+ },
+ saveButtonTitle: {
+ type: String,
+ required: false,
+ default: 'Save comment',
+ },
+ discussion: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isEditing: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ note: this.noteBody,
+ conflictWhileEditing: false,
+ isSubmitting: false,
+ };
+ },
+ components: {
+ confidentialIssue,
+ markdownField,
+ },
+ computed: {
+ ...mapGetters([
+ 'getDiscussionLastNote',
+ 'getIssueDataByProp',
+ 'getNotesDataByProp',
+ 'getUserDataByProp',
+ ]),
+ noteHash() {
+ return `#note_${this.noteId}`;
+ },
+ markdownPreviewPath() {
+ return this.getIssueDataByProp('preview_note_path');
+ },
+ markdownDocsPath() {
+ return this.getNotesDataByProp('markdownDocsPath');
+ },
+ quickActionsDocsPath() {
+ return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
+ },
+ currentUserId() {
+ return this.getUserDataByProp('id');
+ },
+ isDisabled() {
+ return !this.note.length || this.isSubmitting;
+ },
+ isConfidentialIssue() {
+ return this.getIssueDataByProp('confidential');
+ },
+ },
+ methods: {
+ handleUpdate() {
+ this.isSubmitting = true;
+
+ this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
+ this.isSubmitting = false;
+ });
+ },
+ editMyLastNote() {
+ if (this.note === '') {
+ const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
+
+ if (lastNoteInDiscussion) {
+ eventHub.$emit('enterEditMode', {
+ noteId: lastNoteInDiscussion.id,
+ });
+ }
+ }
+ },
+ cancelHandler(shouldConfirm = false) {
+ // Sends information about confirm message and if the textarea has changed
+ this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
+ },
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ watch: {
+ noteBody() {
+ if (this.note === this.noteBody) {
+ this.note = this.noteBody;
+ } else {
+ this.conflictWhileEditing = true;
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div ref="editNoteForm" class="note-edit-form current-note-edit-form">
+ <div
+ v-if="conflictWhileEditing"
+ class="js-conflict-edit-warning alert alert-danger">
+ This comment has changed since you started editing, please review the
+ <a
+ :href="noteHash"
+ target="_blank"
+ rel="noopener noreferrer">updated comment</a>
+ to ensure information is not lost.
+ </div>
+ <div class="flash-container timeline-content"></div>
+ <form
+ class="edit-note common-note-form js-quick-submit gfm-form">
+ <confidentialIssue v-if="isConfidentialIssue" />
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :add-spacing-classes="false">
+ <textarea
+ id="note_note"
+ name="note[note]"
+ class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
+ :data-supports-quick-actions="!isEditing"
+ aria-label="Description"
+ v-model="note"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.meta.enter="handleUpdate()"
+ @keydown.up="editMyLastNote()"
+ @keydown.esc="cancelHandler(true)">
+ </textarea>
+ </markdown-field>
+ <div class="note-form-actions clearfix">
+ <button
+ type="button"
+ @click="handleUpdate()"
+ :disabled="isDisabled"
+ class="js-vue-issue-save btn btn-save">
+ {{saveButtonTitle}}
+ </button>
+ <button
+ @click="cancelHandler()"
+ class="btn btn-cancel note-edit-cancel"
+ type="button">
+ Cancel
+ </button>
+ </div>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_header.vue b/app/assets/javascripts/notes/components/issue_note_header.vue
new file mode 100644
index 00000000000..63aa3d777d0
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_header.vue
@@ -0,0 +1,118 @@
+<script>
+ import { mapActions } from 'vuex';
+ import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+ export default {
+ props: {
+ author: {
+ type: Object,
+ required: true,
+ },
+ createdAt: {
+ type: String,
+ required: true,
+ },
+ actionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ actionTextHtml: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ includeToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isExpanded: true,
+ };
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ computed: {
+ toggleChevronClass() {
+ return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
+ },
+ noteTimestampLink() {
+ return `#note_${this.noteId}`;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setTargetNoteHash',
+ ]),
+ handleToggle() {
+ this.isExpanded = !this.isExpanded;
+ this.$emit('toggleHandler');
+ },
+ updateTargetNoteHash() {
+ this.setTargetNoteHash(this.noteTimestampLink);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="note-header-info">
+ <a :href="author.path">
+ <span class="note-header-author-name">
+ {{author.name}}
+ </span>
+ <span class="note-headline-light">
+ @{{author.username}}
+ </span>
+ </a>
+ <span class="note-headline-light">
+ <span class="note-headline-meta">
+ <template v-if="actionText">
+ {{actionText}}
+ </template>
+ <span
+ v-if="actionTextHtml"
+ v-html="actionTextHtml"
+ class="system-note-message">
+ </span>
+ <a
+ :href="noteTimestampLink"
+ @click="updateTargetNoteHash"
+ class="note-timestamp">
+ <time-ago-tooltip
+ :time="createdAt"
+ tooltip-placement="bottom"
+ />
+ </a>
+ <i
+ class="fa fa-spinner fa-spin editing-spinner"
+ aria-label="Comment is being updated"
+ aria-hidden="true">
+ </i>
+ </span>
+ </span>
+ <div
+ v-if="includeToggle"
+ class="discussion-actions">
+ <button
+ @click="handleToggle"
+ class="note-action-button discussion-toggle-button js-vue-toggle-button"
+ type="button">
+ <i
+ :class="toggleChevronClass"
+ class="fa"
+ aria-hidden="true">
+ </i>
+ Toggle discussion
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_icons.js b/app/assets/javascripts/notes/components/issue_note_icons.js
new file mode 100644
index 00000000000..d8e3cb4bc01
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_icons.js
@@ -0,0 +1,37 @@
+import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg';
+import iconCheck from 'icons/_icon_check_square_o.svg';
+import iconClock from 'icons/_icon_clock_o.svg';
+import iconCodeFork from 'icons/_icon_code_fork.svg';
+import iconComment from 'icons/_icon_comment_o.svg';
+import iconCommit from 'icons/_icon_commit.svg';
+import iconEdit from 'icons/_icon_edit.svg';
+import iconEye from 'icons/_icon_eye.svg';
+import iconEyeSlash from 'icons/_icon_eye_slash.svg';
+import iconMerge from 'icons/_icon_merge.svg';
+import iconMerged from 'icons/_icon_merged.svg';
+import iconRandom from 'icons/_icon_random.svg';
+import iconClosed from 'icons/_icon_status_closed.svg';
+import iconStatusOpen from 'icons/_icon_status_open.svg';
+import iconStopwatch from 'icons/_icon_stopwatch.svg';
+import iconTags from 'icons/_icon_tags.svg';
+import iconUser from 'icons/_icon_user.svg';
+
+export default {
+ icon_arrow_circle_o_right: iconArrowCircle,
+ icon_check_square_o: iconCheck,
+ icon_clock_o: iconClock,
+ icon_code_fork: iconCodeFork,
+ icon_comment_o: iconComment,
+ icon_commit: iconCommit,
+ icon_edit: iconEdit,
+ icon_eye: iconEye,
+ icon_eye_slash: iconEyeSlash,
+ icon_merge: iconMerge,
+ icon_merged: iconMerged,
+ icon_random: iconRandom,
+ icon_status_closed: iconClosed,
+ icon_status_open: iconStatusOpen,
+ icon_stopwatch: iconStopwatch,
+ icon_tags: iconTags,
+ icon_user: iconUser,
+};
diff --git a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue
new file mode 100644
index 00000000000..77af3594c1c
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue
@@ -0,0 +1,28 @@
+<script>
+ import { mapGetters } from 'vuex';
+
+ export default {
+ name: 'singInLinksNotes',
+ computed: {
+ ...mapGetters([
+ 'getNotesDataByProp',
+ ]),
+ registerLink() {
+ return this.getNotesDataByProp('registerPath');
+ },
+ signInLink() {
+ return this.getNotesDataByProp('newSessionPath');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="disabled-comment text-center">
+ Please
+ <a :href="registerLink">register</a>
+ or
+ <a :href="signInLink">sign in</a>
+ to reply
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue
new file mode 100644
index 00000000000..b6fc5e5036f
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_notes_app.vue
@@ -0,0 +1,151 @@
+<script>
+ /* global Flash */
+ import { mapGetters, mapActions } from 'vuex';
+ import store from '../stores/';
+ import * as constants from '../constants';
+ import issueNote from './issue_note.vue';
+ import issueDiscussion from './issue_discussion.vue';
+ import issueSystemNote from './issue_system_note.vue';
+ import issueCommentForm from './issue_comment_form.vue';
+ import placeholderNote from './issue_placeholder_note.vue';
+ import placeholderSystemNote from './issue_placeholder_system_note.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ name: 'issueNotesApp',
+ props: {
+ issueData: {
+ type: Object,
+ required: true,
+ },
+ notesData: {
+ type: Object,
+ required: true,
+ },
+ userData: {
+ type: Object,
+ required: false,
+ default: {},
+ },
+ },
+ store,
+ data() {
+ return {
+ isLoading: true,
+ };
+ },
+ components: {
+ issueNote,
+ issueDiscussion,
+ issueSystemNote,
+ issueCommentForm,
+ loadingIcon,
+ placeholderNote,
+ placeholderSystemNote,
+ },
+ computed: {
+ ...mapGetters([
+ 'notes',
+ 'getNotesDataByProp',
+ ]),
+ },
+ methods: {
+ ...mapActions({
+ actionFetchNotes: 'fetchNotes',
+ poll: 'poll',
+ actionToggleAward: 'toggleAward',
+ scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
+ setNotesData: 'setNotesData',
+ setIssueData: 'setIssueData',
+ setUserData: 'setUserData',
+ setLastFetchedAt: 'setLastFetchedAt',
+ setTargetNoteHash: 'setTargetNoteHash',
+ }),
+ getComponentName(note) {
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === constants.SYSTEM_NOTE) {
+ return placeholderSystemNote;
+ }
+ return placeholderNote;
+ } else if (note.individual_note) {
+ return note.notes[0].system ? issueSystemNote : issueNote;
+ }
+
+ return issueDiscussion;
+ },
+ getComponentData(note) {
+ return note.individual_note ? note.notes[0] : note;
+ },
+ fetchNotes() {
+ return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
+ .then(() => this.initPolling())
+ .then(() => {
+ this.isLoading = false;
+ })
+ .then(() => this.$nextTick())
+ .then(() => this.checkLocationHash())
+ .catch(() => {
+ this.isLoading = false;
+ Flash('Something went wrong while fetching issue comments. Please try again.');
+ });
+ },
+ initPolling() {
+ this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
+
+ this.poll();
+ },
+ checkLocationHash() {
+ const hash = gl.utils.getLocationHash();
+ const element = document.getElementById(hash);
+
+ if (hash && element) {
+ this.setTargetNoteHash(hash);
+ this.scrollToNoteIfNeeded($(element));
+ }
+ },
+ },
+ created() {
+ this.setNotesData(this.notesData);
+ this.setIssueData(this.issueData);
+ this.setUserData(this.userData);
+ },
+ mounted() {
+ this.fetchNotes();
+
+ const parentElement = this.$el.parentElement;
+
+ if (parentElement &&
+ parentElement.classList.contains('js-vue-notes-event')) {
+ parentElement.addEventListener('toggleAward', (event) => {
+ const { awardName, noteId } = event.detail;
+ this.actionToggleAward({ awardName, noteId });
+ });
+ }
+ },
+ };
+</script>
+
+<template>
+ <div id="notes">
+ <div
+ v-if="isLoading"
+ class="js-loading loading">
+ <loading-icon />
+ </div>
+
+ <ul
+ v-if="!isLoading"
+ id="notes-list"
+ class="notes main-notes-list timeline">
+
+ <component
+ v-for="note in notes"
+ :is="getComponentName(note)"
+ :note="getComponentData(note)"
+ :key="note.id"
+ />
+ </ul>
+
+ <issue-comment-form />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_placeholder_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_note.vue
new file mode 100644
index 00000000000..6921d91372f
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_placeholder_note.vue
@@ -0,0 +1,53 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+
+ export default {
+ name: 'issuePlaceholderNote',
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ userAvatarLink,
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserData',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <li class="note being-posted fade-in-half timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="getUserData.path"
+ :img-src="getUserData.avatar_url"
+ :img-size="40"
+ />
+ </div>
+ <div
+ :class="{ discussion: !note.individual_note }"
+ class="timeline-content">
+ <div class="note-header">
+ <div class="note-header-info">
+ <a :href="getUserData.path">
+ <span class="hidden-xs">{{getUserData.name}}</span>
+ <span class="note-headline-light">@{{getUserData.username}}</span>
+ </a>
+ </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>{{note.body}}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue
new file mode 100644
index 00000000000..80a8ef56a83
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue
@@ -0,0 +1,21 @@
+<script>
+ export default {
+ name: 'placeholderSystemNote',
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <li class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <em>{{note.body}}</em>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/issue_system_note.vue b/app/assets/javascripts/notes/components/issue_system_note.vue
new file mode 100644
index 00000000000..5bb8f871b9d
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_system_note.vue
@@ -0,0 +1,55 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import iconsMap from './issue_note_icons';
+ import issueNoteHeader from './issue_note_header.vue';
+
+ export default {
+ name: 'systemNote',
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ issueNoteHeader,
+ },
+ computed: {
+ ...mapGetters([
+ 'targetNoteHash',
+ ]),
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ isTargetNote() {
+ return this.targetNoteHash === this.noteAnchorId;
+ },
+ },
+ created() {
+ this.svg = iconsMap[this.note.system_note_icon_name];
+ },
+ };
+</script>
+
+<template>
+ <li
+ :id="noteAnchorId"
+ :class="{ target: isTargetNote }"
+ class="note system-note timeline-entry">
+ <div class="timeline-entry-inner">
+ <div
+ class="timeline-icon"
+ v-html="svg">
+ </div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <issue-note-header
+ :author="note.author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ :action-text-html="note.note_html" />
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
new file mode 100644
index 00000000000..a6961063c01
--- /dev/null
+++ b/app/assets/javascripts/notes/constants.js
@@ -0,0 +1,11 @@
+export const DISCUSSION_NOTE = 'DiscussionNote';
+export const DISCUSSION = 'discussion';
+export const NOTE = 'note';
+export const SYSTEM_NOTE = 'systemNote';
+export const COMMENT = 'comment';
+export const OPENED = 'opened';
+export const REOPENED = 'reopened';
+export const CLOSED = 'closed';
+export const EMOJI_THUMBSUP = 'thumbsup';
+export const EMOJI_THUMBSDOWN = 'thumbsdown';
+export const NOTEABLE_TYPE = 'Issue';
diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/notes/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
new file mode 100644
index 00000000000..e2ea37408cf
--- /dev/null
+++ b/app/assets/javascripts/notes/index.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import issueNotesApp from './components/issue_notes_app.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#js-vue-notes',
+ components: {
+ issueNotesApp,
+ },
+ data() {
+ const notesDataset = document.getElementById('js-vue-notes').dataset;
+
+ return {
+ issueData: JSON.parse(notesDataset.issueData),
+ currentUserData: JSON.parse(notesDataset.currentUserData),
+ notesData: {
+ lastFetchedAt: notesDataset.lastFetchedAt,
+ discussionsPath: notesDataset.discussionsPath,
+ newSessionPath: notesDataset.newSessionPath,
+ registerPath: notesDataset.registerPath,
+ notesPath: notesDataset.notesPath,
+ markdownDocsPath: notesDataset.markdownDocsPath,
+ quickActionsDocsPath: notesDataset.quickActionsDocsPath,
+ },
+ };
+ },
+ render(createElement) {
+ return createElement('issue-notes-app', {
+ props: {
+ issueData: this.issueData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
new file mode 100644
index 00000000000..5843b97f225
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -0,0 +1,16 @@
+/* globals Autosave */
+import '../../autosave';
+
+export default {
+ methods: {
+ initAutoSave() {
+ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
+ },
+ resetAutoSave() {
+ this.autosave.reset();
+ },
+ setAutoSave() {
+ this.autosave.save();
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/issue_notes_service.js
new file mode 100644
index 00000000000..b51b0cb2013
--- /dev/null
+++ b/app/assets/javascripts/notes/services/issue_notes_service.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default {
+ fetchNotes(endpoint) {
+ return Vue.http.get(endpoint);
+ },
+ deleteNote(endpoint) {
+ return Vue.http.delete(endpoint);
+ },
+ replyToDiscussion(endpoint, data) {
+ return Vue.http.post(endpoint, data, { emulateJSON: true });
+ },
+ updateNote(endpoint, data) {
+ return Vue.http.put(endpoint, data, { emulateJSON: true });
+ },
+ createNewNote(endpoint, data) {
+ return Vue.http.post(endpoint, data, { emulateJSON: true });
+ },
+ poll(data = {}) {
+ const { endpoint, lastFetchedAt } = data;
+ const options = {
+ headers: {
+ 'X-Last-Fetched-At': lastFetchedAt,
+ },
+ };
+
+ return Vue.http.get(endpoint, options);
+ },
+ toggleAward(endpoint, data) {
+ return Vue.http.post(endpoint, data, { emulateJSON: true });
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
new file mode 100644
index 00000000000..13cd74bfa1c
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -0,0 +1,217 @@
+/* global Flash */
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import * as types from './mutation_types';
+import * as utils from './utils';
+import * as constants from '../constants';
+import service from '../services/issue_notes_service';
+import loadAwardsHandler from '../../awards_handler';
+import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
+
+let eTagPoll;
+
+export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
+export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data);
+export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
+export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
+export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
+export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
+export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
+
+export const fetchNotes = ({ commit }, path) => service
+ .fetchNotes(path)
+ .then(res => res.json())
+ .then((res) => {
+ commit(types.SET_INITIAL_NOTES, res);
+ });
+
+export const deleteNote = ({ commit }, note) => service
+ .deleteNote(note.path)
+ .then(() => {
+ commit(types.DELETE_NOTE, note);
+ });
+
+export const updateNote = ({ commit }, { endpoint, note }) => service
+ .updateNote(endpoint, note)
+ .then(res => res.json())
+ .then((res) => {
+ commit(types.UPDATE_NOTE, res);
+ });
+
+export const replyToDiscussion = ({ commit }, { endpoint, data }) => service
+ .replyToDiscussion(endpoint, data)
+ .then(res => res.json())
+ .then((res) => {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
+
+ return res;
+ });
+
+export const createNewNote = ({ commit }, { endpoint, data }) => service
+ .createNewNote(endpoint, data)
+ .then(res => res.json())
+ .then((res) => {
+ if (!res.errors) {
+ commit(types.ADD_NEW_NOTE, res);
+ }
+ return res;
+ });
+
+export const removePlaceholderNotes = ({ commit }) =>
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+export const saveNote = ({ commit, dispatch }, noteData) => {
+ const { note } = noteData.data.note;
+ let placeholderText = note;
+ const hasQuickActions = utils.hasQuickActions(placeholderText);
+ const replyId = noteData.data.in_reply_to_discussion_id;
+ const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
+
+ commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
+ $('.notes-form .flash-container').hide(); // hide previous flash notification
+
+ if (hasQuickActions) {
+ placeholderText = utils.stripQuickActions(placeholderText);
+ }
+
+ if (placeholderText.length) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ noteBody: placeholderText,
+ replyId,
+ });
+ }
+
+ if (hasQuickActions) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ isSystemNote: true,
+ noteBody: utils.getQuickActionText(note),
+ replyId,
+ });
+ }
+
+ return dispatch(methodToDispatch, noteData)
+ .then((res) => {
+ const { errors } = res;
+ const commandsChanges = res.commands_changes;
+
+ if (hasQuickActions && errors && Object.keys(errors).length) {
+ eTagPoll.makeRequest();
+
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash('Commands applied', 'notice', $(noteData.flashContainer));
+ }
+
+ if (commandsChanges) {
+ if (commandsChanges.emoji_award) {
+ const votesBlock = $('.js-awards-block').eq(0);
+
+ loadAwardsHandler()
+ .then((awardsHandler) => {
+ awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ Flash(
+ 'Something went wrong while adding your award. Please try again.',
+ null,
+ $(noteData.flashContainer),
+ );
+ });
+ }
+
+ if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
+ sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
+ }
+ }
+
+ if (errors && errors.commands_only) {
+ Flash(errors.commands_only, 'notice', $(noteData.flashContainer));
+ }
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+ return res;
+ });
+};
+
+const pollSuccessCallBack = (resp, commit, state, getters) => {
+ if (resp.notes && resp.notes.length) {
+ const { notesById } = getters;
+
+ resp.notes.forEach((note) => {
+ if (notesById[note.id]) {
+ commit(types.UPDATE_NOTE, note);
+ } else if (note.type === constants.DISCUSSION_NOTE) {
+ const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (discussion) {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ });
+ }
+
+ commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt);
+
+ return resp;
+};
+
+export const poll = ({ commit, state, getters }) => {
+ const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
+
+ eTagPoll = new Poll({
+ resource: service,
+ method: 'poll',
+ data: requestData,
+ successCallback: resp => resp.json()
+ .then(data => pollSuccessCallBack(data, commit, state, getters)),
+ errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ service.poll(requestData);
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ eTagPoll.restart();
+ } else {
+ eTagPoll.stop();
+ }
+ });
+};
+
+export const fetchData = ({ commit, state, getters }) => {
+ const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
+
+ service.poll(requestData)
+ .then(resp => resp.json)
+ .then(data => pollSuccessCallBack(data, commit, state, getters))
+ .catch(() => Flash('Something went wrong while fetching latest comments.'));
+};
+
+export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => {
+ commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
+};
+
+export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => {
+ const { endpoint, awardName } = data;
+
+ return service
+ .toggleAward(endpoint, { name: awardName })
+ .then(res => res.json())
+ .then(() => {
+ dispatch('toggleAward', data);
+ });
+};
+
+export const scrollToNoteIfNeeded = (context, el) => {
+ if (!gl.utils.isInViewport(el[0])) {
+ gl.utils.scrollToElement(el);
+ }
+};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
new file mode 100644
index 00000000000..1f0c6af6156
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -0,0 +1,31 @@
+import _ from 'underscore';
+
+export const notes = state => state.notes;
+export const targetNoteHash = state => state.targetNoteHash;
+
+export const getNotesData = state => state.notesData;
+export const getNotesDataByProp = state => prop => state.notesData[prop];
+
+export const getIssueData = state => state.issueData;
+export const getIssueDataByProp = state => prop => state.issueData[prop];
+
+export const getUserData = state => state.userData || {};
+export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
+
+export const notesById = state => state.notes.reduce((acc, note) => {
+ note.notes.every(n => Object.assign(acc, { [n.id]: n }));
+ return acc;
+}, {});
+
+const reverseNotes = array => array.slice(0).reverse();
+const isLastNote = (note, state) => !note.system &&
+ state.userData && note.author &&
+ note.author.id === state.userData.id;
+
+export const getCurrentUserLastNote = state => _.flatten(
+ reverseNotes(state.notes)
+ .map(note => reverseNotes(note.notes)),
+ ).find(el => isLastNote(el, state));
+
+export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
+ .find(el => isLastNote(el, state));
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
new file mode 100644
index 00000000000..8e0c8531bbc
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+
+ // holds endpoints and permissions provided through haml
+ notesData: {},
+ userData: {},
+ issueData: {},
+ },
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
new file mode 100644
index 00000000000..cd71533ba9d
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -0,0 +1,14 @@
+export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
+export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
+export const DELETE_NOTE = 'DELETE_NOTE';
+export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
+export const SET_NOTES_DATA = 'SET_NOTES_DATA';
+export const SET_ISSUE_DATA = 'SET_ISSUE_DATA';
+export const SET_USER_DATA = 'SET_USER_DATA';
+export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
+export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
+export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
+export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
+export const TOGGLE_AWARD = 'TOGGLE_AWARD';
+export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
+export const UPDATE_NOTE = 'UPDATE_NOTE';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
new file mode 100644
index 00000000000..3b2b2089d6e
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -0,0 +1,151 @@
+import * as utils from './utils';
+import * as types from './mutation_types';
+import * as constants from '../constants';
+
+export default {
+ [types.ADD_NEW_NOTE](state, note) {
+ const { discussion_id, type } = note;
+ const noteData = {
+ expanded: true,
+ id: discussion_id,
+ individual_note: !(type === constants.DISCUSSION_NOTE),
+ notes: [note],
+ reply_id: discussion_id,
+ };
+
+ state.notes.push(noteData);
+ },
+
+ [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj) {
+ noteObj.notes.push(note);
+ }
+ },
+
+ [types.DELETE_NOTE](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj.individual_note) {
+ state.notes.splice(state.notes.indexOf(noteObj), 1);
+ } else {
+ const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ noteObj.notes.splice(noteObj.notes.indexOf(comment), 1);
+
+ if (!noteObj.notes.length) {
+ state.notes.splice(state.notes.indexOf(noteObj), 1);
+ }
+ }
+ },
+
+ [types.REMOVE_PLACEHOLDER_NOTES](state) {
+ const { notes } = state;
+
+ for (let i = notes.length - 1; i >= 0; i -= 1) {
+ const note = notes[i];
+ const children = note.notes;
+
+ if (children.length && !note.individual_note) { // remove placeholder from discussions
+ for (let j = children.length - 1; j >= 0; j -= 1) {
+ if (children[j].isPlaceholderNote) {
+ children.splice(j, 1);
+ }
+ }
+ } else if (note.isPlaceholderNote) { // remove placeholders from state root
+ notes.splice(i, 1);
+ }
+ }
+ },
+
+ [types.SET_NOTES_DATA](state, data) {
+ Object.assign(state, { notesData: data });
+ },
+
+ [types.SET_ISSUE_DATA](state, data) {
+ Object.assign(state, { issueData: data });
+ },
+
+ [types.SET_USER_DATA](state, data) {
+ Object.assign(state, { userData: data });
+ },
+ [types.SET_INITIAL_NOTES](state, notesData) {
+ const notes = [];
+
+ notesData.forEach((note) => {
+ // To support legacy notes, should be very rare case.
+ if (note.individual_note && note.notes.length > 1) {
+ note.notes.forEach((n) => {
+ const nn = Object.assign({}, note);
+ nn.notes = [n]; // override notes array to only have one item to mimick individual_note
+ notes.push(nn);
+ });
+ } else {
+ notes.push(note);
+ }
+ });
+
+ Object.assign(state, { notes });
+ },
+
+ [types.SET_LAST_FETCHED_AT](state, fetchedAt) {
+ Object.assign(state, { lastFetchedAt: fetchedAt });
+ },
+
+ [types.SET_TARGET_NOTE_HASH](state, hash) {
+ Object.assign(state, { targetNoteHash: hash });
+ },
+
+ [types.SHOW_PLACEHOLDER_NOTE](state, data) {
+ let notesArr = state.notes;
+ if (data.replyId) {
+ notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes;
+ }
+
+ notesArr.push({
+ individual_note: true,
+ isPlaceholderNote: true,
+ placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
+ notes: [
+ {
+ body: data.noteBody,
+ },
+ ],
+ });
+ },
+
+ [types.TOGGLE_AWARD](state, data) {
+ const { awardName, note } = data;
+ const { id, name, username } = state.userData;
+
+ const hasEmojiAwardedByCurrentUser = note.award_emoji
+ .filter(emoji => emoji.name === data.awardName && emoji.user.id === id);
+
+ if (hasEmojiAwardedByCurrentUser.length) {
+ // If current user has awarded this emoji, remove it.
+ note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1);
+ } else {
+ note.award_emoji.push({
+ name: awardName,
+ user: { id, name, username },
+ });
+ }
+ },
+
+ [types.TOGGLE_DISCUSSION](state, { discussionId }) {
+ const discussion = utils.findNoteObjectById(state.notes, discussionId);
+
+ discussion.expanded = !discussion.expanded;
+ },
+
+ [types.UPDATE_NOTE](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj.individual_note) {
+ noteObj.notes.splice(0, 1, note);
+ } else {
+ const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
+ }
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
new file mode 100644
index 00000000000..6074115e855
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -0,0 +1,31 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+
+const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+
+export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
+
+export const getQuickActionText = (note) => {
+ let text = 'Applying command';
+ const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
+
+ const executedCommands = quickActions.filter((command) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(note);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ text = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ text = `Applying command to ${commandDescription}`;
+ }
+ }
+
+ return text;
+};
+
+export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
+
+export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
+
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
index 7695b04db74..3e5d6d15909 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -72,7 +72,7 @@
};
</script>
<template>
- <div>
+ <div class="ci-job-dropdown-container">
<button
v-tooltip
type="button"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
index 1f5ed3f1074..3933509a6f4 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -75,7 +75,7 @@
};
</script>
<template>
- <div>
+ <div class="ci-job-component">
<a
v-tooltip
v-if="job.status.details_path"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index d8856e10668..f46d21bd6d7 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -26,7 +26,7 @@
};
</script>
<template>
- <span>
+ <span class="ci-job-name-component">
<ci-icon
:status="status" />
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index 0be141eb5f9..78b257bf192 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -20,7 +20,7 @@ import './shortcuts_navigation';
Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
Mousetrap.bind('r', (function(_this) {
return function() {
- _this.replyWithSelectedText();
+ _this.replyWithSelectedText(isMergeRequest);
return false;
};
})(this));
@@ -38,9 +38,15 @@ import './shortcuts_navigation';
}
}
- ShortcutsIssuable.prototype.replyWithSelectedText = function() {
+ ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
var quote, documentFragment, el, selected, separator;
- var replyField = $('.js-main-target-form #note_note');
+ let replyField;
+
+ if (isMergeRequest) {
+ replyField = $('.js-main-target-form #note_note');
+ } else {
+ replyField = $('.js-main-target-form .js-vue-comment-form');
+ }
documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) {
@@ -57,6 +63,7 @@ import './shortcuts_navigation';
quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n";
});
+
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(a, current) {
@@ -64,7 +71,7 @@ import './shortcuts_navigation';
});
// Trigger autosave
- replyField.trigger('input');
+ replyField.trigger('input').trigger('change');
// Trigger autosize
var event = document.createEvent('Event');
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
index 2d682215cf8..d32fe4abc7d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -6,6 +6,7 @@ import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
+import eventHub from '../../event_hub';
export default {
data() {
@@ -20,6 +21,9 @@ export default {
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
+ eventHub.$on('timeTrackingUpdated', (data) => {
+ this.quickActionListened(null, data);
+ });
},
quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate'];
diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
new file mode 100644
index 00000000000..397d16331d5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
@@ -0,0 +1,16 @@
+<script>
+ export default {
+ name: 'confidentialIssueWarning',
+ };
+</script>
+<template>
+ <div class="confidential-issue-warning">
+ <i
+ aria-hidden="true"
+ class="fa fa-eye-slash">
+ </i>
+ <span>
+ This is a confidential issue. Your comment will not be visible to the public.
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 4e10bbc7408..759d30c9c7c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -5,19 +5,30 @@
export default {
props: {
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: false,
default: '',
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ addSpacingClasses: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ },
},
data() {
return {
markdownPreview: '',
+ referencedCommands: '',
+ referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
@@ -26,35 +37,48 @@
markdownHeader,
markdownToolbar,
},
+ computed: {
+ shouldShowReferencedUsers() {
+ const referencedUsersThreshold = 10;
+ return this.referencedUsers.length >= referencedUsersThreshold;
+ },
+ },
methods: {
toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown;
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ const text = this.$slots.textarea[0].elm.value;
+
if (!this.previewMarkdown) {
this.markdownPreview = '';
- } else {
+ } else if (text) {
this.markdownPreviewLoading = true;
- this.$http.post(
- this.markdownPreviewUrl,
- {
- /*
- Can't use `$refs` as the component is technically in the parent component
- so we access the VNode & then get the element
- */
- text: this.$slots.textarea[0].elm.value,
- },
- )
- .then(resp => resp.json())
- .then((data) => {
- this.markdownPreviewLoading = false;
- this.markdownPreview = data.body;
+ this.$http.post(this.markdownPreviewPath, { text })
+ .then(resp => resp.json())
+ .then((data) => {
+ this.renderMarkdown(data);
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ } else {
+ this.renderMarkdown();
+ }
+ },
+ renderMarkdown(data = {}) {
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body || 'Nothing to preview.';
- this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
- });
- })
- .catch(() => new Flash('Error loading markdown preview'));
+ if (data.references) {
+ this.referencedCommands = data.references.commands;
+ this.referencedUsers = data.references.users;
}
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
},
},
mounted() {
@@ -74,7 +98,8 @@
<template>
<div
- class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
+ class="md-area js-vue-markdown-field"
+ :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
ref="gl-form">
<markdown-header
:preview-markdown="previewMarkdown"
@@ -94,7 +119,9 @@
</i>
</a>
<markdown-toolbar
- :markdown-docs="markdownDocs" />
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ />
</div>
</div>
<div
@@ -108,5 +135,27 @@
Loading...
</span>
</div>
+ <template v-if="previewMarkdown && !markdownPreviewLoading">
+ <div
+ v-if="referencedCommands"
+ v-html="referencedCommands"
+ class="referenced-commands"></div>
+ <div
+ v-if="shouldShowReferencedUsers"
+ class="referenced-users">
+ <span>
+ <i
+ class="fa fa-exclamation-triangle"
+ aria-hidden="true">
+ </i>
+ You are about to add
+ <strong>
+ <span class="js-referenced-users-count">
+ {{referencedUsers.length}}
+ </span>
+ </strong> people to the discussion. Proceed with caution.
+ </span>
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 93252293ba6..65fe7bbd94e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,10 +1,14 @@
<script>
export default {
props: {
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ },
},
};
</script>
@@ -12,22 +16,77 @@
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
- <a
- :href="markdownDocs"
- target="_blank"
- tabindex="-1">
- Markdown is supported
- </a>
+ <template v-if="!quickActionsDocsPath && markdownDocsPath">
+ <a
+ :href="markdownDocsPath"
+ target="_blank"
+ tabindex="-1">
+ Markdown is supported
+ </a>
+ </template>
+ <template v-if="quickActionsDocsPath && markdownDocsPath">
+ <a
+ :href="markdownDocsPath"
+ target="_blank"
+ tabindex="-1">
+ Markdown
+ </a>
+ and
+ <a
+ :href="quickActionsDocsPath"
+ target="_blank"
+ tabindex="-1">
+ quick actions
+ </a>
+ are supported
+ </template>
</div>
- <button
- class="toolbar-button markdown-selector"
- type="button"
- tabindex="-1">
- <i
- class="fa fa-file-image-o toolbar-button-icon"
- aria-hidden="true">
- </i>
- Attach a file
- </button>
+ <span class="uploading-container">
+ <span class="uploading-progress-container hide">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"></i>
+ <span class="attaching-file-message"></span>
+ <span class="uploading-progress">0%</span>
+ <span class="uploading-spinner">
+ <i
+ class="fa fa-spinner fa-spin toolbar-button-icon"
+ aria-hidden="true"></i>
+ </span>
+ </span>
+ <span class="uploading-error-container hide">
+ <span class="uploading-error-icon">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"></i>
+ </span>
+ <span class="uploading-error-message"></span>
+ <button
+ class="retry-uploading-link"
+ type="button">
+ Try again
+ </button>
+ or
+ <button
+ class="attach-new-file markdown-selector"
+ type="button">
+ attach a new file
+ </button>
+ </span>
+ <button
+ class="markdown-selector button-attach-file"
+ tabindex="-1"
+ type="button">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"></i>
+ Attach a file
+ </button>
+ <button
+ class="btn btn-default btn-xs hide button-cancel-uploading-files"
+ type="button">
+ Cancel
+ </button>
+ </span>
</div>
</template>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index e16fbbf43b5..68a51c5a461 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -16,6 +16,7 @@
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; }
+.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 5871383a57b..110b171676a 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -368,6 +368,10 @@
transform: translateY(0);
}
+.comment-type-dropdown.open .dropdown-menu {
+ display: block;
+}
+
.filtered-search-box-input-container {
.dropdown-menu,
.dropdown-menu-nav {
@@ -729,6 +733,7 @@
#{$selector}.dropdown-menu,
#{$selector}.dropdown-menu-nav {
li {
+ display: block;
padding: 0 1px;
&:hover {
@@ -748,7 +753,8 @@
}
a,
- button {
+ button,
+ .menu-item {
border-radius: 0;
padding: 8px 16px;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 26920869bec..01fffa717e9 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd;
/*
Pipeline Graph
*/
-$stage-hover-bg: #eaf3fc;
-$stage-hover-border: #d1e7fc;
-$action-icon-color: #d6d6d6;
+$stage-hover-bg: $gray-darker;
+$ci-action-icon-size: 22px;
+$pipeline-dropdown-line-height: 20px;
+$pipeline-dropdown-status-icon-size: 18px;
/*
Pipeline Schedules
diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss
index 54fa4109f8b..b711bd12c73 100644
--- a/app/assets/stylesheets/new_nav.scss
+++ b/app/assets/stylesheets/new_nav.scss
@@ -8,15 +8,23 @@ header.navbar-gitlab-new {
border-bottom: 0;
.header-content {
+ display: -webkit-flex;
+ display: flex;
padding-left: 0;
.title-container {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-align-items: stretch;
align-items: stretch;
+ -webkit-flex: 1 1 auto;
+ flex: 1 1 auto;
padding-top: 0;
overflow: visible;
}
.title {
+ display: -webkit-flex;
display: flex;
padding-right: 0;
color: currentColor;
@@ -27,6 +35,7 @@ header.navbar-gitlab-new {
}
> a {
+ display: -webkit-flex;
display: flex;
align-items: center;
padding-right: $gl-padding;
@@ -177,6 +186,7 @@ header.navbar-gitlab-new {
}
.navbar-sub-nav {
+ display: -webkit-flex;
display: flex;
margin-bottom: 0;
color: $indigo-200;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index ab5a901da71..4b0b238a767 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -498,6 +498,7 @@
color: $gray-darkest;
display: block;
margin: 16px 0 0;
+ font-size: 85%;
.author_link {
color: $gray-darkest;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index e2177f96aee..518bb270b88 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -250,6 +250,10 @@ ul.related-merge-requests > li {
}
}
+.discussion-reply-holder .note-edit-form {
+ display: block;
+}
+
@media (min-width: $screen-sm-min) {
.emoji-block .row {
display: flex;
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 3fb02e9964f..b3bab082a35 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -55,6 +55,10 @@
display: -webkit-flex;
display: flex;
}
+
+ .dropdown-menu.dropdown-menu-align-right {
+ margin-top: -2px;
+ }
}
.form-horizontal {
@@ -306,3 +310,7 @@
}
}
}
+
+.member-form-control {
+ @include new-style-dropdown;
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 9d51c0b7a8a..d7f53a74825 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -174,17 +174,6 @@
vertical-align: top;
}
- .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item {
- display: flex;
- align-items: center;
-
- .ci-status-text,
- .ci-status-icon {
- top: 0;
- margin-right: 10px;
- }
- }
-
.normal {
line-height: 28px;
}
@@ -291,6 +280,7 @@
.dropdown-toggle {
.fa {
+ margin-left: 0;
color: inherit;
}
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 9558924bbcb..8932cff22a8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -20,10 +20,6 @@
}
}
-.new-note {
- display: none;
-}
-
.new-note,
.note-edit-form {
.note-form-actions {
@@ -202,6 +198,10 @@
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
+
+ &.is-replying {
+ padding-bottom: $gl-padding;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index fbfe5d3c682..764984c5772 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -100,6 +100,20 @@ ul.notes {
}
}
+ .editing-spinner {
+ display: none;
+ }
+
+ &.is-requesting {
+ .note-timestamp {
+ display: none;
+ }
+
+ .editing-spinner {
+ display: inline-block;
+ }
+ }
+
&.is-editing {
.note-header,
.note-text,
@@ -402,6 +416,10 @@ ul.notes {
.note-header-info {
min-width: 0;
padding-bottom: 8px;
+
+ &.discussion {
+ padding-bottom: 0;
+ }
}
.system-note .note-header-info {
@@ -814,10 +832,6 @@ ul.notes {
}
}
-.discussion-notes .flash-container {
- margin-bottom: 0;
-}
-
// Merge request notes in diffs
.diff-file {
// Diff is inline
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 51656669c98..9f6b363568e 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -40,7 +40,7 @@
.btn.btn-retry:hover,
.btn.btn-retry:focus {
- border-color: $gray-darkest;
+ border-color: $dropdown-toggle-active-border-color;
background-color: $white-normal;
}
@@ -206,8 +206,8 @@
.stage-cell {
.mini-pipeline-graph-dropdown-toggle svg {
- height: 22px;
- width: 22px;
+ height: $ci-action-icon-size;
+ width: $ci-action-icon-size;
position: absolute;
top: -1px;
left: -1px;
@@ -219,7 +219,7 @@
display: inline-block;
position: relative;
vertical-align: middle;
- height: 22px;
+ height: $ci-action-icon-size;
margin: 3px 0;
+ .stage-container {
@@ -308,7 +308,7 @@
a {
text-decoration: none;
- color: $gl-text-color-secondary;
+ color: $gl-text-color;
}
svg {
@@ -432,7 +432,11 @@
width: 186px;
margin-bottom: 10px;
white-space: normal;
- color: $gl-text-color-secondary;
+
+ // ensure .build-content has hover style when action-icon is hovered
+ .ci-job-dropdown-container:hover .build-content {
+ @extend .build-content:hover;
+ }
// Action Icons in big pipeline-graph nodes
.ci-action-icon-container .ci-action-icon-wrapper {
@@ -445,11 +449,11 @@
&:hover {
background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
}
svg {
- fill: $border-color;
+ fill: $gl-text-color-secondary;
position: relative;
left: -1px;
top: -1px;
@@ -475,19 +479,10 @@
background-color: transparent;
border: none;
padding: 0;
- color: $gl-text-color-secondary;
&:focus {
outline: none;
}
-
- &:hover {
- color: $gl-text-color;
-
- .dropdown-counter-badge {
- color: $gl-text-color;
- }
- }
}
.build-content {
@@ -502,8 +497,7 @@
a.build-content:hover,
button.build-content:hover {
background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-border;
- color: $gl-text-color;
+ border: 1px solid $dropdown-toggle-active-border-color;
}
@@ -564,7 +558,6 @@
// Triggers the dropdown in the big pipeline graph
.dropdown-counter-badge {
- color: $border-color;
font-weight: 100;
font-size: 15px;
position: absolute;
@@ -606,8 +599,8 @@ button.mini-pipeline-graph-dropdown-toggle {
background-color: $white-light;
border-width: 1px;
border-style: solid;
- width: 22px;
- height: 22px;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
margin: 0;
padding: 0;
transition: all 0.2s linear;
@@ -669,105 +662,119 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
+@include new-style-dropdown('.big-pipeline-graph-dropdown-menu');
+@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu');
+
// dropdown content for big and mini pipeline
.big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu {
width: 195px;
max-width: 195px;
- li {
- padding: 2px 3px;
- }
-
.scrollable-menu {
padding: 0;
max-height: 245px;
overflow: auto;
}
- // Action icon on the right
- a.ci-action-icon-wrapper {
- color: $action-icon-color;
- border: 1px solid $action-icon-color;
- border-radius: 20px;
- width: 22px;
- height: 22px;
- padding: 2px 0 0 5px;
- cursor: pointer;
- float: right;
- margin: -26px 9px 0 0;
- font-size: 12px;
- background-color: $white-light;
+ li {
+ position: relative;
- &:hover,
- &:focus {
- background-color: $stage-hover-bg;
- border: 1px solid transparent;
+ // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
+ &:hover > .mini-pipeline-graph-dropdown-item,
+ &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
+ @extend .mini-pipeline-graph-dropdown-item:hover;
}
- svg {
- width: 22px;
- height: 22px;
- left: -6px;
- position: relative;
- top: -3px;
- fill: $action-icon-color;
- }
+ // Action icon on the right
+ a.ci-action-icon-wrapper {
+ border-radius: 50%;
+ border: 1px solid $border-color;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
+ padding: 2px 0 0 5px;
+ font-size: 12px;
+ background-color: $white-light;
+ position: absolute;
+ top: 50%;
+ right: $gl-padding;
+ margin-top: -#{$ci-action-icon-size / 2};
- &:hover svg,
- &:focus svg {
- fill: $gl-text-color;
- }
- }
+ &:hover,
+ &:focus {
+ background-color: $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
+ }
- // link to the build
- .mini-pipeline-graph-dropdown-item {
- padding: 3px 7px 4px;
- clear: both;
- font-weight: $gl-font-weight-normal;
- line-height: 1.428571429;
- white-space: nowrap;
- margin: 0 5px;
- border-radius: 3px;
+ svg {
+ fill: $gl-text-color-secondary;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
+ left: -6px;
+ position: relative;
+ top: -3px;
+ }
- // build name
- .ci-build-text,
- .ci-status-text {
- font-weight: 200;
- overflow: hidden;
+ &:hover svg,
+ &:focus svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ // link to the build
+ .mini-pipeline-graph-dropdown-item {
+ padding: 3px 7px 4px;
+ align-items: center;
+ clear: both;
+ display: flex;
+ font-weight: normal;
+ line-height: $line-height-base;
white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 70%;
- color: $gl-text-color-secondary;
- margin-left: 2px;
- display: inline-block;
- top: 1px;
- vertical-align: text-bottom;
- position: relative;
+ border-radius: 3px;
- @media (max-width: $screen-xs-max) {
- max-width: 60%;
+ .ci-job-name-component {
+ align-items: center;
+ display: flex;
+ flex: 1;
}
- }
- // status icon on the left
- .ci-status-icon {
- top: 3px;
- position: relative;
+ // build name
+ .ci-build-text,
+ .ci-status-text {
+ font-weight: 200;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 70%;
+ margin-left: 2px;
+ display: inline-block;
- > svg {
- overflow: visible;
- width: 18px;
- height: 18px;
+ @media (max-width: $screen-xs-max) {
+ max-width: 60%;
+ }
}
- }
- &:hover,
- &:focus {
- outline: none;
- text-decoration: none;
- color: $gl-text-color;
- background-color: $stage-hover-bg;
+ .ci-status-icon {
+ @extend .append-right-8;
+
+ position: relative;
+
+ > svg {
+ width: $pipeline-dropdown-status-icon-size;
+ height: $pipeline-dropdown-status-icon-size;
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ outline: none;
+ text-decoration: none;
+ background-color: $stage-hover-bg;
+ }
}
}
}
@@ -776,16 +783,9 @@ button.mini-pipeline-graph-dropdown-toggle {
.big-pipeline-graph-dropdown-menu {
width: 195px;
min-width: 195px;
- left: auto;
- right: -195px;
- top: -4px;
+ left: 100%;
+ top: -10px;
box-shadow: 0 1px 5px $black-transparent;
-
- .mini-pipeline-graph-dropdown-item {
- .ci-status-icon {
- top: -1px;
- }
- }
}
/**
@@ -806,15 +806,14 @@ button.mini-pipeline-graph-dropdown-toggle {
}
&::before {
- left: -5px;
- margin-top: -6px;
+ left: -6px;
+ margin-top: 3px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
- left: -4px;
- margin-top: -9px;
+ left: -5px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 1d92ea11bda..97922e39ba8 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base
end
def check_password_expiration
- if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication?
+ if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user?
return redirect_to new_profile_password_path
end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index af5f683bab5..18fd8eb114d 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -3,6 +3,7 @@ module NotesActions
extend ActiveSupport::Concern
included do
+ before_action :set_polling_interval_header, only: [:index]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
end
@@ -12,14 +13,18 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at }
- @notes = notes_finder.execute.inc_relations_for_view
- @notes = prepare_notes_for_rendering(@notes)
+ notes = notes_finder.execute
+ .inc_relations_for_view
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
- @notes.each do |note|
- next if note.cross_reference_not_visible_for?(current_user)
+ notes = prepare_notes_for_rendering(notes)
- notes_json[:notes] << note_json(note)
- end
+ notes_json[:notes] =
+ if noteable.discussions_rendered_on_frontend?
+ note_serializer.represent(notes)
+ else
+ notes.map { |note| note_json(note) }
+ end
render json: notes_json
end
@@ -82,22 +87,27 @@ module NotesActions
}
if note.persisted?
- attrs.merge!(
- valid: true,
- id: note.id,
- discussion_id: note.discussion_id(noteable),
- html: note_html(note),
- note: note.note
- )
+ attrs[:valid] = true
- discussion = note.to_discussion(noteable)
- unless discussion.individual_note?
+ if noteable.nil? || noteable.discussions_rendered_on_frontend?
+ attrs.merge!(note_serializer.represent(note))
+ else
attrs.merge!(
- discussion_resolvable: discussion.resolvable?,
-
- diff_discussion_html: diff_discussion_html(discussion),
- discussion_html: discussion_html(discussion)
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
+ html: note_html(note),
+ note: note.note
)
+
+ discussion = note.to_discussion(noteable)
+ unless discussion.individual_note?
+ attrs.merge!(
+ discussion_resolvable: discussion.resolvable?,
+
+ diff_discussion_html: diff_discussion_html(discussion),
+ discussion_html: discussion_html(discussion)
+ )
+ end
end
else
attrs.merge!(
@@ -168,6 +178,10 @@ module NotesActions
)
end
+ def set_polling_interval_header
+ Gitlab::PollingInterval.set_header(response, interval: 6_000)
+ end
+
def noteable
@noteable ||= notes_finder.target
end
@@ -180,6 +194,10 @@ module NotesActions
@notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end
+ def note_serializer
+ NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
+ end
+
def note_project
return @note_project if defined?(@note_project)
return nil unless project
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index aa8cf630032..fda944adecd 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -1,8 +1,6 @@
class PasswordsController < Devise::PasswordsController
- include Gitlab::CurrentSettings
-
before_action :resource_from_email, only: [:create]
- before_action :check_password_authentication_available, only: [:create]
+ before_action :prevent_ldap_reset, only: [:create]
before_action :throttle_reset, only: [:create]
def edit
@@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController
self.resource = resource_class.find_by_email(email)
end
- def check_password_authentication_available
- return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?)
+ def prevent_ldap_reset
+ return unless resource&.ldap_user?
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
- alert: "Password authentication is unavailable."
+ alert: "Cannot reset password for LDAP user."
end
def throttle_reset
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index c423761ab24..7beb52dd8e8 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def authorize_change_password!
- render_404 unless @user.allow_password_authentication?
+ render_404 if @user.ldap_user?
end
def user_params
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 1afaceac567..349b19f72e2 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: IssueSerializer.new.represent(@issue)
+ render json: serializer.represent(@issue)
end
end
end
+ def discussions
+ notes = @issue.notes
+ .inc_relations_for_view
+ .includes(:noteable)
+ .fresh
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
+
+ prepare_notes_for_rendering(notes)
+
+ discussions = Discussion.build_collection(notes, @issue)
+
+ render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
+ end
+
def create
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -143,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do
if @issue.valid?
- render json: IssueSerializer.new.represent(@issue)
+ render json: serializer.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
@@ -287,4 +301,8 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to new_user_session_path, notice: notice
end
+
+ def serializer
+ IssueSerializer.new(current_user: current_user, project: issue.project)
+ end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 7e0d3b5c979..c8dd2275730 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -24,7 +24,6 @@ class IssuableFinder
include CreatedAtFilter
NONE = '0'.freeze
- IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze
attr_accessor :current_user, :params
@@ -68,7 +67,7 @@ class IssuableFinder
# grouping and counting within that query.
#
def count_by_state
- count_params = params.merge(state: nil, sort: nil, for_counting: true)
+ count_params = params.merge(state: nil, sort: nil)
labels_count = label_names.any? ? label_names.count : 1
finder = self.class.new(current_user, count_params)
counts = Hash.new(0)
@@ -91,16 +90,6 @@ class IssuableFinder
execute.find_by!(*params)
end
- def state_counter_cache_key
- cache_key(state_counter_cache_key_components)
- end
-
- def clear_caches!
- state_counter_cache_key_components_permutations.each do |components|
- Rails.cache.delete(cache_key(components))
- end
- end
-
def group
return @group if defined?(@group)
@@ -432,20 +421,4 @@ class IssuableFinder
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
-
- def state_counter_cache_key_components
- opts = params.with_indifferent_access
- opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
- opts.delete_if { |_, value| value.blank? }
-
- ['issuables_count', klass.to_ability_name, opts.sort]
- end
-
- def state_counter_cache_key_components_permutations
- [state_counter_cache_key_components]
- end
-
- def cache_key(components)
- Digest::SHA1.hexdigest(components.flatten.join('-'))
- end
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 0ec42a4e6eb..aa9cef6b08c 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -54,44 +54,10 @@ class IssuesFinder < IssuableFinder
project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
end
- # Anonymous users can't see any confidential issues.
- #
- # Users without access to see _all_ confidential issues (as in
- # `user_can_see_all_confidential_issues?`) are more complicated, because they
- # can see confidential issues where:
- # 1. They are an assignee.
- # 2. They are an author.
- #
- # That's fine for most cases, but if we're just counting, we need to cache
- # effectively. If we cached this accurately, we'd have a cache key for every
- # authenticated user without sufficient access to the project. Instead, when
- # we are counting, we treat them as if they can't see any confidential issues.
- #
- # This does mean the counts may be wrong for those users, but avoids an
- # explosion in cache keys.
- def user_cannot_see_confidential_issues?(for_counting: false)
+ def user_cannot_see_confidential_issues?
return false if user_can_see_all_confidential_issues?
- current_user.blank? || for_counting || params[:for_counting]
- end
-
- def state_counter_cache_key_components
- extra_components = [
- user_can_see_all_confidential_issues?,
- user_cannot_see_confidential_issues?(for_counting: true)
- ]
-
- super + extra_components
- end
-
- def state_counter_cache_key_components_permutations
- # Ignore the last two, as we'll provide both options for them.
- components = super.first[0..-3]
-
- [
- components + [false, true],
- components + [true, false]
- ]
+ current_user.blank?
end
def by_assignee(items)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 36bb7015fa1..017df8f6794 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -303,7 +303,7 @@ module ApplicationHelper
end
def show_new_nav?
- cookies["new_nav"] == "true"
+ true
end
def collapsed_sidebar?
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 04955ed625e..b93f5f0af1c 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -84,6 +84,18 @@ module ApplicationSettingsHelper
end
end
+ def key_restriction_options_for_select(type)
+ bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits|
+ ["Must be at least #{bits} bits", bits]
+ end
+
+ [
+ ['Are allowed', 0],
+ *bit_size_options,
+ ['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE]
+ ]
+ end
+
def repository_storages_options_for_select
options = Gitlab.config.repositories.storages.map do |name, storage|
["#{name} - #{storage['path']}", name]
@@ -116,6 +128,9 @@ module ApplicationSettingsHelper
:domain_blacklist_enabled,
:domain_blacklist_raw,
:domain_whitelist_raw,
+ :dsa_key_restriction,
+ :ecdsa_key_restriction,
+ :ed25519_key_restriction,
:email_author_in_body,
:enabled_git_access_protocol,
:gravatar_enabled,
@@ -159,6 +174,7 @@ module ApplicationSettingsHelper
:repository_storages,
:require_two_factor_authentication,
:restricted_visibility_levels,
+ :rsa_key_restriction,
:send_user_confirmation_email,
:sentry_dsn,
:sentry_enabled,
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 9247b1f72de..b5dece38de1 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,9 +1,9 @@
module FormHelper
- def form_errors(model)
+ def form_errors(model, type: 'form')
return unless model.errors.any?
pluralized = 'error'.pluralize(model.errors.count)
- headline = "The form contains the following #{pluralized}:"
+ headline = "The #{type} contains the following #{pluralized}:"
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 4123a96911f..dd159d12aa0 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -68,7 +68,7 @@ module GroupsHelper
def group_title_link(group, hidable: false)
link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do
output =
- if show_new_nav?
+ if show_new_nav? && !Rails.env.test?
image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16)
else
""
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 2a748ce0a75..0fcd3347095 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -35,7 +35,7 @@ module IssuablesHelper
def serialize_issuable(issuable)
case issuable
when Issue
- IssueSerializer.new.represent(issuable).to_json
+ IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json
when MergeRequest
MergeRequestSerializer
.new(current_user: current_user, project: issuable.project)
@@ -210,9 +210,9 @@ module IssuablesHelper
canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference,
isConfidential: issuable.confidential,
- markdownPreviewUrl: preview_markdown_path(@project),
- markdownDocs: help_page_path('user/markdown'),
- projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
+ markdownPreviewPath: preview_markdown_path(@project),
+ markdownDocsPath: help_page_path('user/markdown'),
+ projectsAutocompletePath: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path,
@@ -240,16 +240,9 @@ module IssuablesHelper
}
end
- def issuables_count_for_state(issuable_type, state, finder: nil)
- finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
- cache_key = finder.state_counter_cache_key
-
- @counts ||= {}
- @counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
- finder.count_by_state
- end
-
- @counts[cache_key][state]
+ def issuables_count_for_state(issuable_type, state)
+ finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
+ finder.count_by_state[state]
end
def close_issuable_url(issuable)
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 7e1ccb23e9e..853ce827061 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -137,7 +137,7 @@ module IssuesHelper
end
def awards_sort(awards)
- awards.sort_by do |award, notes|
+ awards.sort_by do |award, award_emojis|
if award == "thumbsup"
0
elsif award == "thumbsdown"
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index e857e837c16..8c5e258f519 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -93,11 +93,13 @@ module NotesHelper
end
end
- def notes_url
+ def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet)
- snippet_notes_path(@snippet)
+ snippet_notes_path(@snippet, params)
else
- project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)
+ params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore)
+
+ project_noteable_notes_path(@project, params)
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index c5490a2d1a8..0bf94fd30db 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -62,7 +62,7 @@ module ProjectsHelper
project_link = link_to project_path(project), { class: "project-item-select-holder" } do
output =
- if show_new_nav?
+ if show_new_nav? && !Rails.env.test?
project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16)
else
""
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 08fd97cd048..c98f65c7644 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -22,8 +22,14 @@ module SystemNoteHelper
'duplicate' => 'icon_clone'
}.freeze
+ def system_note_icon_name(note)
+ ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ end
+
def icon_for_system_note(note)
- icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ icon_name = system_note_icon_name(note)
custom_icon(icon_name) if icon_name
end
+
+ extend self
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 8e446ff6dd8..3568e72e463 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters
}x
+ # Setting a key restriction to `-1` means that all keys of this type are
+ # forbidden.
+ FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
+ SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+
serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize
@@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0 }
+ SUPPORTED_KEY_TYPES.each do |type|
+ validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
+ end
+
+ validates :allowed_key_types, presence: true
+
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level)
@@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base
end
before_validation :ensure_uuid!
+
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base
default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
disabled_oauth_sign_in_sources: [],
domain_whitelist: Settings.gitlab['domain_whitelist'],
+ dsa_key_restriction: 0,
+ ecdsa_key_restriction: 0,
+ ed25519_key_restriction: 0,
gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil,
help_page_hide_commercial_content: false,
@@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base
max_attachment_size: Settings.gitlab['max_attachment_size'],
password_authentication_enabled: Settings.gitlab['password_authentication_enabled'],
performance_bar_allowed_group_id: nil,
+ rsa_key_restriction: 0,
plantuml_enabled: false,
plantuml_url: nil,
project_export_enabled: true,
@@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base
usage_ping_can_be_configured? && super
end
+ def allowed_key_types
+ SUPPORTED_KEY_TYPES.select do |type|
+ key_restriction_for(type) != FORBIDDEN_KEY_VALUE
+ end
+ end
+
+ def key_restriction_for(type)
+ attr_name = "#{type}_key_restriction"
+
+ has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
+ end
+
private
def ensure_uuid!
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 91b62dabbcd..4d1a15c53aa 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base
scope :downvotes, -> { where(name: DOWNVOTE_NAME) }
scope :upvotes, -> { where(name: UPVOTE_NAME) }
+ after_save :expire_etag_cache
+ after_destroy :expire_etag_cache
+
class << self
def votes_for_collection(ids, type)
select('name', 'awardable_id', 'COUNT(*) as count')
@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base
def upvote?
self.name == UPVOTE_NAME
end
+
+ def expire_etag_cache
+ awardable.try(:expire_etag_cache)
+ end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index d41c88b4e30..c943365016f 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -251,6 +251,28 @@ class Commit
project.repository.next_branch("cherry-pick-#{short_id}", mild: true)
end
+ def cherry_pick_description(user)
+ message_body = "(cherry picked from commit #{sha})"
+
+ if merged_merge_request?(user)
+ commits_in_merge_request = merged_merge_request(user).commits
+
+ if commits_in_merge_request.present?
+ message_body << "\n"
+
+ commits_in_merge_request.reverse.each do |commit_in_merge|
+ message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}"
+ end
+ end
+ end
+
+ message_body
+ end
+
+ def cherry_pick_message(user)
+ %Q{#{message}\n\n#{cherry_pick_description(user)}}
+ end
+
def revert_description(user)
if merged_merge_request?(user)
"This reverts merge request #{merged_merge_request(user).to_reference}"
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index c7bdc997eca..1c4ddabcad5 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -24,6 +24,10 @@ module Noteable
DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
end
+ def discussions_rendered_on_frontend?
+ false
+ end
+
def discussion_notes
notes
end
@@ -38,7 +42,7 @@ module Noteable
def grouped_diff_discussions(*args)
# Doesn't use `discussion_notes`, because this may include commit diff notes
- # besides MR diff notes, that we do no want to display on the MR Changes tab.
+ # besides MR diff notes, that we do not want to display on the MR Changes tab.
notes.inc_relations_for_view.grouped_diff_discussions(*args)
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index d1cec7613af..b80da7b246a 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -81,6 +81,10 @@ class Discussion
last_note.author
end
+ def updated?
+ last_updated_at != created_at
+ end
+
def id
first_note.discussion_id(context_noteable)
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index dfcd4030ec3..8c7d492e605 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base
end
end
+ def discussions_rendered_on_frontend?
+ true
+ end
+
def update_project_counter_caches?
state_changed? || confidential_changed?
end
diff --git a/app/models/key.rb b/app/models/key.rb
index 49bc26122fa..a6b4dcfec0d 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,6 +1,7 @@
require 'digest/md5'
class Key < ActiveRecord::Base
+ include Gitlab::CurrentSettings
include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i
@@ -12,14 +13,18 @@ class Key < ActiveRecord::Base
validates :title,
presence: true,
length: { maximum: 255 }
+
validates :key,
presence: true,
length: { maximum: 5000 },
format: { with: /\A(ssh|ecdsa)-.*\Z/ }
+
validates :fingerprint,
uniqueness: true,
presence: { message: 'cannot be generated' }
+ validate :key_meets_restrictions
+
delegate :name, :email, to: :user, prefix: true
after_commit :add_to_shell, on: :create
@@ -80,6 +85,10 @@ class Key < ActiveRecord::Base
SystemHooksService.new.execute_hooks_for(self, :destroy)
end
+ def public_key
+ @public_key ||= Gitlab::SSHPublicKey.new(key)
+ end
+
private
def generate_fingerprint
@@ -87,7 +96,27 @@ class Key < ActiveRecord::Base
return unless self.key.present?
- self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint
+ self.fingerprint = public_key.fingerprint
+ end
+
+ def key_meets_restrictions
+ restriction = current_application_settings.key_restriction_for(public_key.type)
+
+ if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE
+ errors.add(:key, forbidden_key_type_message)
+ elsif public_key.bits < restriction
+ errors.add(:key, "must be at least #{restriction} bits")
+ end
+ end
+
+ def forbidden_key_type_message
+ allowed_types =
+ current_application_settings
+ .allowed_key_types
+ .map(&:upcase)
+ .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+
+ "type is forbidden. Must be #{allowed_types}"
end
def notify_user
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index ca3a1806ee8..7a817eedec2 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -605,6 +605,8 @@ class MergeRequest < ActiveRecord::Base
self.merge_requests_closing_issues.delete_all
closes_issues(current_user).each do |issue|
+ next if issue.is_a?(ExternalIssue)
+
self.merge_requests_closing_issues.create!(issue: issue)
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index a752c897d63..1073c115630 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base
end
end
+ def expire_etag_cache
+ return unless noteable&.discussions_rendered_on_frontend?
+
+ key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
+ project,
+ target_type: noteable_type.underscore,
+ target_id: noteable_id
+ )
+ Gitlab::EtagCaching::Store.new.touch(key)
+ end
+
private
def keep_around_commit
@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base
def set_discussion_id
self.discussion_id ||= discussion_class.discussion_id(self)
end
-
- def expire_etag_cache
- return unless for_issue?
-
- key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
- noteable.project,
- target_type: noteable_type.underscore,
- target_id: noteable.id
- )
- Gitlab::EtagCaching::Store.new.touch(key)
- end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index b3fa51a14f7..5474c8eeb68 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -908,7 +908,7 @@ class Repository
committer = user_to_committer(user)
- create_commit(message: commit.message,
+ create_commit(message: commit.cherry_pick_message(user),
author: {
email: commit.author_email,
name: commit.author_name,
diff --git a/app/models/user.rb b/app/models/user.rb
index 78e7c750c3b..68ec93a3ec5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -603,7 +603,7 @@ class User < ActiveRecord::Base
end
def require_personal_access_token_creation_for_git_auth?
- return false if allow_password_authentication? || ldap_user?
+ return false if current_application_settings.password_authentication_enabled? || ldap_user?
PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none?
end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 5c7c2204374..f2315bb3dbb 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -84,7 +84,7 @@ class WikiPage
# The formatted title of this page.
def title
if @attributes[:title]
- self.class.unhyphenize(@attributes[:title])
+ CGI.unescape_html(self.class.unhyphenize(@attributes[:title]))
else
""
end
diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb
new file mode 100644
index 00000000000..6e03cd02392
--- /dev/null
+++ b/app/serializers/award_emoji_entity.rb
@@ -0,0 +1,4 @@
+class AwardEmojiEntity < Grape::Entity
+ expose :name
+ expose :user, using: API::Entities::UserSafe
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
new file mode 100644
index 00000000000..0a92e3f8167
--- /dev/null
+++ b/app/serializers/discussion_entity.rb
@@ -0,0 +1,10 @@
+class DiscussionEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :reply_id
+ expose :expanded?, as: :expanded
+
+ expose :notes, using: NoteEntity
+
+ expose :individual_note?, as: :individual_note
+end
diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb
new file mode 100644
index 00000000000..ed5e1224bb2
--- /dev/null
+++ b/app/serializers/discussion_serializer.rb
@@ -0,0 +1,3 @@
+class DiscussionSerializer < BaseSerializer
+ entity DiscussionEntity
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index bd5211b8e58..61c7a428745 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
+ expose :milestone, using: API::Entities::Milestone
+ expose :labels, using: LabelEntity
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index c189a4992da..0d6feb78173 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity
expose :due_date
expose :moved_to_id
expose :project_id
- expose :milestone, using: API::Entities::Milestone
- expose :labels, using: LabelEntity
expose :web_url do |issue|
project_issue_path(issue.project, issue)
end
+
+ expose :current_user do
+ expose :can_create_note do |issue|
+ can?(request.current_user, :create_note, issue.project)
+ end
+
+ expose :can_update do |issue|
+ can?(request.current_user, :update_issue, issue)
+ end
+ end
+
+ expose :create_note_path do |issue|
+ project_notes_path(issue.project, target_type: 'issue', target_id: issue.id)
+ end
+
+ expose :preview_note_path do |issue|
+ preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id)
+ end
end
diff --git a/app/serializers/note_attachment_entity.rb b/app/serializers/note_attachment_entity.rb
new file mode 100644
index 00000000000..1ad50568ab9
--- /dev/null
+++ b/app/serializers/note_attachment_entity.rb
@@ -0,0 +1,5 @@
+class NoteAttachmentEntity < Grape::Entity
+ expose :url
+ expose :filename
+ expose :image?, as: :image
+end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
new file mode 100644
index 00000000000..7d50e0ff10d
--- /dev/null
+++ b/app/serializers/note_entity.rb
@@ -0,0 +1,60 @@
+class NoteEntity < API::Entities::Note
+ include RequestAwareEntity
+
+ expose :type
+
+ expose :author, using: NoteUserEntity
+
+ expose :human_access do |note|
+ note.project.team.human_max_access(note.author_id)
+ end
+
+ unexpose :note, as: :body
+ expose :note
+
+ expose :redacted_note_html, as: :note_html
+
+ expose :last_edited_at, if: -> (note, _) { note.edited? }
+ expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? }
+
+ expose :current_user do
+ expose :can_edit do |note|
+ Ability.can_edit_note?(request.current_user, note)
+ end
+ end
+
+ expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
+ SystemNoteHelper.system_note_icon_name(note)
+ end
+
+ expose :discussion_id do |note|
+ note.discussion_id(request.noteable)
+ end
+
+ expose :emoji_awardable?, as: :emoji_awardable
+ expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
+ expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
+ if note.for_personal_snippet?
+ toggle_award_emoji_snippet_note_path(note.noteable, note)
+ else
+ toggle_award_emoji_project_note_path(note.project, note.id)
+ end
+ end
+
+ expose :report_abuse_path do |note|
+ new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
+ end
+
+ expose :path do |note|
+ if note.for_personal_snippet?
+ snippet_note_path(note.noteable, note)
+ else
+ project_note_path(note.project, note)
+ end
+ end
+
+ expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
+ expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
+ delete_attachment_project_note_path(note.project, note)
+ end
+end
diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb
new file mode 100644
index 00000000000..2afe40d7a34
--- /dev/null
+++ b/app/serializers/note_serializer.rb
@@ -0,0 +1,3 @@
+class NoteSerializer < BaseSerializer
+ entity NoteEntity
+end
diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb
new file mode 100644
index 00000000000..7289f3a0222
--- /dev/null
+++ b/app/serializers/note_user_entity.rb
@@ -0,0 +1,3 @@
+class NoteUserEntity < UserEntity
+ unexpose :web_url
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
new file mode 100644
index 00000000000..49a71ebac61
--- /dev/null
+++ b/app/serializers/user_serializer.rb
@@ -0,0 +1,3 @@
+class UserSerializer < BaseSerializer
+ entity UserEntity
+end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 1486db046b5..8b967b78052 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -56,6 +56,7 @@ class IssuableBaseService < BaseService
params.delete(:assignee_id)
params.delete(:due_date)
params.delete(:canonical_issue_id)
+ params.delete(:project)
end
filter_assignee(issuable)
@@ -244,9 +245,7 @@ class IssuableBaseService < BaseService
new_assignees = issuable.assignees.to_a
affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees)
- # Don't clear the project cache, because it will be handled by the
- # appropriate service (close / reopen / merge / etc.).
- invalidate_cache_counts(issuable, users: affected_assignees.compact, skip_project_cache: true)
+ invalidate_cache_counts(issuable, users: affected_assignees.compact)
after_update(issuable)
issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update')
@@ -340,18 +339,9 @@ class IssuableBaseService < BaseService
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
- def invalidate_cache_counts(issuable, users: [], skip_project_cache: false)
+ def invalidate_cache_counts(issuable, users: [])
users.each do |user|
user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end
-
- unless skip_project_cache
- case issuable
- when Issue
- IssuesFinder.new(nil, project_id: issuable.project_id).clear_caches!
- when MergeRequest
- MergeRequestsFinder.new(nil, project_id: issuable.target_project_id).clear_caches!
- end
- end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 8d918ccc635..deb4990eb4f 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -6,7 +6,7 @@ module Issues
handle_move_between_iids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- update(issue)
+ move_issue_to_new_project(issue) || update(issue)
end
def before_update(issue)
@@ -74,6 +74,17 @@ module Issues
end
end
+ def move_issue_to_new_project(issue)
+ target_project = params.delete(:target_project)
+
+ return unless target_project &&
+ issue.can_move?(current_user, target_project) &&
+ target_project != issue.project
+
+ update(issue)
+ Issues::MoveService.new(project, current_user).execute(issue, target_project)
+ end
+
private
def get_issue_if_allowed(project, iid)
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
index e6a68d983ef..3047268b2d1 100644
--- a/app/services/projects/after_import_service.rb
+++ b/app/services/projects/after_import_service.rb
@@ -1,7 +1,6 @@
module Projects
class AfterImportService
- RESERVED_REFS_REGEXP =
- %r{\Arefs/(?:#{Regexp.union(*Repository::RESERVED_REFS_NAMES)})/}
+ RESERVED_REF_PREFIXES = Repository::RESERVED_REFS_NAMES.map { |n| File.join('refs', n, '/') }
def initialize(project)
@project = project
@@ -9,7 +8,7 @@ module Projects
def execute
Projects::HousekeepingService.new(@project).execute do
- repository.delete_refs(*garbage_refs)
+ repository.delete_all_refs_except(RESERVED_REF_PREFIXES)
end
rescue Projects::HousekeepingService::LeaseTaken => e
Rails.logger.info(
@@ -18,10 +17,6 @@ module Projects
private
- def garbage_refs
- @garbage_refs ||= repository.all_ref_names_except(RESERVED_REFS_REGEXP)
- end
-
def repository
@repository ||= @project.repository
end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index c7832c47e1a..9cdb9935bea 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -505,6 +505,24 @@ module QuickActions
end
end
+ desc 'Move this issue to another project.'
+ explanation do |path_to_project|
+ "Moves this issue to #{path_to_project}."
+ end
+ params 'path/to/project'
+ condition do
+ issuable.is_a?(Issue) &&
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :move do |target_project_path|
+ target_project = Project.find_by_full_path(target_project_path)
+
+ if target_project.present?
+ @updates[:target_project] = target_project
+ end
+ end
+
def extract_users(params)
return [] if params.nil?
diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb
new file mode 100644
index 00000000000..204be827941
--- /dev/null
+++ b/app/validators/key_restriction_validator.rb
@@ -0,0 +1,29 @@
+class KeyRestrictionValidator < ActiveModel::EachValidator
+ FORBIDDEN = -1
+
+ def self.supported_sizes(type)
+ Gitlab::SSHPublicKey.supported_sizes(type)
+ end
+
+ def self.supported_key_restrictions(type)
+ [0, *supported_sizes(type), FORBIDDEN]
+ end
+
+ def validate_each(record, attribute, value)
+ unless valid_restriction?(value)
+ record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}")
+ end
+ end
+
+ private
+
+ def supported_sizes_message
+ sizes = self.class.supported_sizes(options[:type])
+ sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+ end
+
+ def valid_restriction?(value)
+ choices = self.class.supported_key_restrictions(options[:type])
+ choices.include?(value)
+ end
+end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 734a08c61fa..b38433326fc 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -42,12 +42,7 @@
= link_to "(?)", help_page_path("integration/bitbucket")
and GitLab.com
= link_to "(?)", help_page_path("integration/gitlab")
- .form-group
- %label.control-label.col-sm-2 Enabled Git access protocols
- .col-sm-10
- = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
- %span.help-block#clone-protocol-help
- Allow only the selected protocols to be used for Git access.
+
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
@@ -55,6 +50,20 @@
= f.check_box :project_export_enabled
Project export enabled
+ .form-group
+ %label.control-label.col-sm-2 Enabled Git access protocols
+ .col-sm-10
+ = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
+ %span.help-block#clone-protocol-help
+ Allow only the selected protocols to be used for Git access.
+
+ - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ - field_name = :"#{type}_key_restriction"
+ .form-group
+ = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
+
%fieldset
%legend Account and Limit Settings
.form-group
@@ -153,7 +162,7 @@
.checkbox
= f.label :password_authentication_enabled do
= f.check_box :password_authentication_enabled
- Password authentication enabled
+ Sign-in enabled
- if omniauth_enabled? && button_based_providers.any?
.form-group
= f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2'
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index 8ed23ac4919..dcfb7f0c32d 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -6,14 +6,14 @@
- tooltip = "#{subject.name} - #{status.label}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip } do
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= custom_icon(status.icon)
%span.ci-build-text= subject.name
- else
- .mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip } }
+ .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= custom_icon(status.icon)
%span.ci-build-text= subject.name
- if status.has_action?
- = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title } do
+ = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
= custom_icon(status.action_icon)
diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml
index c1dabeed387..25e90924413 100644
--- a/app/views/discussions/_headline.html.haml
+++ b/app/views/discussions/_headline.html.haml
@@ -5,7 +5,7 @@
by
= link_to_member(@project, discussion.resolved_by, avatar: false)
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
-- elsif discussion.last_updated_at != discussion.created_at
+- elsif discussion.updated?
.discussion-headline-light.js-discussion-headline
Last updated
- if discussion.last_updated_by
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 12bc092d216..837ef385dd5 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -12,6 +12,8 @@
- content_for :breadcrumbs_extra do
= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do
= icon('rss')
+ %span.icon-label
+ Subscribe
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues
- if group_issues_exists
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index b32cfe158bb..1d875f81041 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -74,8 +74,6 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
- %li
- = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation")
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml
index 2c1c23d6ea9..c84d7053cd6 100644
--- a/app/views/layouts/header/_new.html.haml
+++ b/app/views/layouts/header/_new.html.haml
@@ -4,7 +4,7 @@
.header-content
.title-container
%h1.title
- = link_to root_path, title: 'Dashboard' do
+ = link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo
%span.logo-text.hidden-xs
= render 'shared/logo_type.svg'
@@ -37,13 +37,13 @@
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
%li
- = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('issues')
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
%li
- = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold')
- merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
@@ -68,8 +68,6 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
- %li
- = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation")
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
index 26d9640e98a..448f6abedf2 100644
--- a/app/views/layouts/nav/_profile.html.haml
+++ b/app/views/layouts/nav/_profile.html.haml
@@ -29,7 +29,7 @@
= link_to profile_emails_path, title: 'Emails' do
%span
Emails
- - if current_user.allow_password_authentication?
+ - unless current_user.ldap_user?
= nav_link(controller: :passwords) do
= link_to edit_profile_password_path, title: 'Password' do
%span
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index d2a60ac2867..103446243e5 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -1,6 +1,12 @@
%li.key-list-item
.pull-left.append-right-10
- = icon 'key', class: "settings-list-icon hidden-xs"
+ - if key.valid?
+ = icon 'key', class: 'settings-list-icon hidden-xs'
+ - else
+ = icon 'exclamation-triangle', class: 'settings-list-icon hidden-xs has-tooltip',
+ title: key.errors.full_messages.join(', ')
+
+
.key-list-item-info
= link_to path_to_key(key, is_admin), class: "title" do
= key.title
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index d44603c638c..77521417f47 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -16,6 +16,7 @@
%strong= @key.last_used_at.try(:to_s, :medium) || 'N/A'
.col-md-8
+ = form_errors(@key, type: 'key') unless @key.valid?
%p
%span.light Fingerprint:
%code.key-fingerprint= @key.fingerprint
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index f08dcc0c242..9e7fe556d88 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -18,26 +18,6 @@
= scheme.name
.col-sm-12
%hr
- .col-lg-4.profile-settings-sidebar#new-navigation
- %h4.prepend-top-0
- New Navigation
- %p
- This setting allows you to turn on or off the new upcoming navigation concept.
- .col-lg-8.syntax-theme
- .nav-wip
- %p
- The new navigation is currently a work-in-progress concept and is currently only usable on wide-screens. There are a number of improvements that we are working on in order to further refine our navigation.
- %p
- %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/32794', target: 'blank' } Learn more
- about the improvements that are coming soon!
- = label_tag do
- .preview= image_tag "old_nav.png"
- %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? }
- Old
- = label_tag do
- .preview= image_tag "new_nav.png"
- %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? }
- New
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 97041b87c48..71424593f2e 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,10 +1,5 @@
- referenced_users = local_assigns.fetch(:referenced_users, nil)
-- if defined?(@issue) && @issue.confidential?
- .confidential-issue-warning
- = confidential_icon(@issue)
- %span This is a confidential issue. Your comment will not be visible to the public.
-
.md-area
.md-header
%ul.nav-links.clearfix
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 8b095f4ca10..483f28c74f2 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,7 +1,17 @@
+- @gfm_form = true
+
- content_for :note_actions do
- if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
-#notes
- = render 'shared/notes/notes_with_form', :autocomplete => true
+%section.js-vue-notes-event
+ #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
+ register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
+ new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
+ markdown_docs_path: help_page_path('user/markdown'),
+ quick_actions_docs_path: help_page_path('user/project/quick_actions'),
+ notes_path: notes_url,
+ last_fetched_at: Time.now.to_i,
+ issue_data: serialize_issuable(@issue),
+ current_user_data: UserSerializer.new.represent(current_user).to_json } }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index de0f1de057d..fd7ff176c5e 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,11 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'notes'
+
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
@@ -23,7 +28,7 @@
= icon('eye-slash', class: 'is-confidential')
= issuable_meta(@issue, @project, "Issue")
- .issuable-actions
+ .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
@@ -36,8 +41,8 @@
- if @issue.author && current_user != @issue.author
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
- if can_update_issue
- %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- if can_report_spam
%li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- if can_update_issue || can_report_spam
@@ -74,7 +79,7 @@
.content-block.emoji-block
.row
- .col-sm-8
+ .col-sm-8.js-issue-note-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential?
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index a2e819fb3a7..f3c44c94a5c 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -17,7 +17,7 @@
.issuable-meta
= issuable_meta(@merge_request, @project, "Merge request")
- .issuable-actions
+ .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index f22b6c9a6c2..cb706d80f23 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -4,9 +4,9 @@
- if can_update && is_current_user
= link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method,
- class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
+ class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
= link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method,
- class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
+ class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
- elsif can_update && !is_current_user
= render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
- elsif issuable.author
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index daa05990ae9..d8144a39b23 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -2,7 +2,7 @@
- button_action = issuable.closed? ? 'reopen' : 'close'
- display_button_action = button_action.capitalize
- button_responsive_class = 'hidden-xs hidden-sm'
-- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button"
+- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- button_method = issuable_close_reopen_button_method(issuable)
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index eae04c9bbb8..e3e86709b8f 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -17,9 +17,9 @@
- elsif !current_user
.disabled-comment.text-center.prepend-top-default
Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link'
or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
to comment
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml
new file mode 100644
index 00000000000..8ec78bbd41f
--- /dev/null
+++ b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml
@@ -0,0 +1,5 @@
+---
+title: Add settings for minimum SSH key strength and allowed key type
+merge_request: 13712
+author: Cory Hinshaw
+type: added
diff --git a/changelogs/unreleased/35686-unescape-wiki-title.yml b/changelogs/unreleased/35686-unescape-wiki-title.yml
new file mode 100644
index 00000000000..4b2b7078163
--- /dev/null
+++ b/changelogs/unreleased/35686-unescape-wiki-title.yml
@@ -0,0 +1,5 @@
+---
+title: Unescape HTML characters in Wiki title
+merge_request: 13942
+author: Jacopo Beschi @jacopo-beschi
+type: fixed
diff --git a/changelogs/unreleased/36061-mr-ref-instrument.yml b/changelogs/unreleased/36061-mr-ref-instrument.yml
new file mode 100644
index 00000000000..b34eed43172
--- /dev/null
+++ b/changelogs/unreleased/36061-mr-ref-instrument.yml
@@ -0,0 +1,5 @@
+---
+title: Instrument MergeRequest#fetch_ref
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/37194-fix-mr-widget-merge-button-dropdown-caret.yml b/changelogs/unreleased/37194-fix-mr-widget-merge-button-dropdown-caret.yml
new file mode 100644
index 00000000000..6a2fa7ff547
--- /dev/null
+++ b/changelogs/unreleased/37194-fix-mr-widget-merge-button-dropdown-caret.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Merge when pipeline succeeds button dropdown caret icon horizontal alignment
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/37202-revert-changes-to-signing-enabled.yml b/changelogs/unreleased/37202-revert-changes-to-signing-enabled.yml
new file mode 100644
index 00000000000..ddbe79cb498
--- /dev/null
+++ b/changelogs/unreleased/37202-revert-changes-to-signing-enabled.yml
@@ -0,0 +1,5 @@
+---
+title: Reverts changes made to signin_enabled.
+merge_request: 13956
+author:
+type: fixed
diff --git a/changelogs/unreleased/add_message_to_the_404_page.yml b/changelogs/unreleased/add_message_to_the_404_page.yml
new file mode 100644
index 00000000000..f567796fe9f
--- /dev/null
+++ b/changelogs/unreleased/add_message_to_the_404_page.yml
@@ -0,0 +1,5 @@
+---
+title: Changed message and title on the 404 page
+merge_request:
+author: Branka Martinovic
+type: added
diff --git a/changelogs/unreleased/bvl-validate-po-files.yml b/changelogs/unreleased/bvl-validate-po-files.yml
new file mode 100644
index 00000000000..f840b2c3973
--- /dev/null
+++ b/changelogs/unreleased/bvl-validate-po-files.yml
@@ -0,0 +1,4 @@
+---
+title: Validate PO-files in static analysis
+merge_request: 13000
+author:
diff --git a/changelogs/unreleased/fly-out-nav-hiding-fix.yml b/changelogs/unreleased/fly-out-nav-hiding-fix.yml
new file mode 100644
index 00000000000..0688ea89d16
--- /dev/null
+++ b/changelogs/unreleased/fly-out-nav-hiding-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed fly-out nav flashing in & out
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml b/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml
new file mode 100644
index 00000000000..edf11484d1f
--- /dev/null
+++ b/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml
@@ -0,0 +1,5 @@
+---
+title: Make Gitaly PostUploadPack mandatory
+merge_request: 13953
+author:
+type: changed
diff --git a/changelogs/unreleased/issue_36820.yml b/changelogs/unreleased/issue_36820.yml
new file mode 100644
index 00000000000..ec5fb6ac079
--- /dev/null
+++ b/changelogs/unreleased/issue_36820.yml
@@ -0,0 +1,5 @@
+---
+title: Remove closing external issues by reference error
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/move-action.yml b/changelogs/unreleased/move-action.yml
new file mode 100644
index 00000000000..65eceae3ef9
--- /dev/null
+++ b/changelogs/unreleased/move-action.yml
@@ -0,0 +1,4 @@
+---
+title: Allow users to move issues to other projects using a / command
+merge_request: 13436
+author: Manolis Mavrofidis
diff --git a/changelogs/unreleased/rouge-2-2-1.yml b/changelogs/unreleased/rouge-2-2-1.yml
new file mode 100644
index 00000000000..2d8879e5574
--- /dev/null
+++ b/changelogs/unreleased/rouge-2-2-1.yml
@@ -0,0 +1,5 @@
+---
+title: Bump rouge to v2.2.1
+merge_request: 13887
+author:
+type: other
diff --git a/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml b/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml
new file mode 100644
index 00000000000..602ca358b8b
--- /dev/null
+++ b/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml
@@ -0,0 +1,5 @@
+---
+title: Add 'from commit' information to cherry-picked commits
+merge_request: 13475
+author: Saverio Miroddi
+type: added
diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb
index 370a976b64a..5b455a8065a 100644
--- a/config/initializers/8_metrics.rb
+++ b/config/initializers/8_metrics.rb
@@ -122,6 +122,7 @@ def instrument_classes(instrumentation)
# Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/36061
instrumentation.instrument_instance_method(MergeRequest, :ensure_ref_fetched)
+ instrumentation.instrument_instance_method(MergeRequest, :fetch_ref)
end
# rubocop:enable Metrics/AbcSize
diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb
index eb589ecdb52..fd0167aa476 100644
--- a/config/initializers/fast_gettext.rb
+++ b/config/initializers/fast_gettext.rb
@@ -1,4 +1,7 @@
-FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po
+FastGettext.add_text_domain 'gitlab',
+ path: File.join(Rails.root, 'locale'),
+ type: :po,
+ ignore_fuzzy: true
FastGettext.default_text_domain = 'gitlab'
FastGettext.default_available_locales = Gitlab::I18n.available_locales
FastGettext.default_locale = :en
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 06928c7b9ce..c703a7294ed 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -308,6 +308,7 @@ constraints(ProjectUrlConstrainer.new) do
get :can_create_branch
get :realtime_changes
post :create_merge_request
+ get :discussions, format: :json
end
collection do
post :bulk_update
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 7d63a42d7d8..ad88e48550d 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -55,6 +55,7 @@ var config = {
monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js',
+ notes: './notes/index.js',
pdf_viewer: './blob/pdf_viewer.js',
pipelines: './pipelines/pipelines_bundle.js',
pipelines_charts: './pipelines/pipelines_charts.js',
@@ -194,6 +195,7 @@ var config = {
'merge_conflicts',
'monitoring',
'notebook_viewer',
+ 'notes',
'pdf_viewer',
'pipelines',
'pipelines_details',
diff --git a/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb
new file mode 100644
index 00000000000..5b6079002c0
--- /dev/null
+++ b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb
@@ -0,0 +1,29 @@
+class AddMinimumKeyLengthToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # A key restriction has these possible states:
+ #
+ # * -1 means "this key type is completely disabled"
+ # * 0 means "all keys of this type are valid"
+ # * > 0 means "keys must have at least this many bits to be valid"
+ #
+ # The default is 0, for backward compatibility
+ add_column_with_default :application_settings, :rsa_key_restriction, :integer, default: 0
+ add_column_with_default :application_settings, :dsa_key_restriction, :integer, default: 0
+ add_column_with_default :application_settings, :ecdsa_key_restriction, :integer, default: 0
+ add_column_with_default :application_settings, :ed25519_key_restriction, :integer, default: 0
+ end
+
+ def down
+ remove_column :application_settings, :rsa_key_restriction
+ remove_column :application_settings, :dsa_key_restriction
+ remove_column :application_settings, :ecdsa_key_restriction
+ remove_column :application_settings, :ed25519_key_restriction
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0f4b0c0c3b3..434d1326419 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -129,6 +129,10 @@ ActiveRecord::Schema.define(version: 20170824162758) do
t.boolean "password_authentication_enabled"
t.boolean "project_export_enabled", default: true, null: false
t.boolean "hashed_storage_enabled", default: false, null: false
+ t.integer "rsa_key_restriction", default: 0, null: false
+ t.integer "dsa_key_restriction", default: 0, null: false
+ t.integer "ecdsa_key_restriction", default: 0, null: false
+ t.integer "ed25519_key_restriction", default: 0, null: false
end
create_table "audit_events", force: :cascade do |t|
diff --git a/doc/api/settings.md b/doc/api/settings.md
index 94a9f8265fb..b78f1252108 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -48,7 +48,11 @@ Example response:
"plantuml_enabled": false,
"plantuml_url": null,
"terminal_max_session_time": 0,
- "polling_interval_multiplier": 1.0
+ "polling_interval_multiplier": 1.0,
+ "rsa_key_restriction": 0,
+ "dsa_key_restriction": 0,
+ "ecdsa_key_restriction": 0,
+ "ed25519_key_restriction": 0,
}
```
@@ -88,6 +92,10 @@ PUT /application/settings
| `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. |
| `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time. |
| `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling. |
+| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys.
+| `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys.
+| `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys.
+| `ed25519_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `0` (no restriction). `-1` disables ED25519 keys.
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/application/settings?signup_enabled=false&default_project_visibility=internal
@@ -125,6 +133,10 @@ Example response:
"plantuml_enabled": false,
"plantuml_url": null,
"terminal_max_session_time": 0,
- "polling_interval_multiplier": 1.0
+ "polling_interval_multiplier": 1.0,
+ "rsa_key_restriction": 0,
+ "dsa_key_restriction": 0,
+ "ecdsa_key_restriction": 0,
+ "ed25519_key_restriction": 0,
}
```
diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md
index 2458cb959ab..f094546c3bd 100644
--- a/doc/ci/examples/README.md
+++ b/doc/ci/examples/README.md
@@ -50,12 +50,15 @@ Apart from those, here is an collection of tutorials and guides on setting up yo
- **Articles:**
- [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
+### Code quality analysis
+
+- [Analyze code quality with the Code Climate CLI](code_climate.md)
+
### Other
- [Using `dpl` as deployment tool](deployment/README.md)
- [Repositories with examples for various languages](https://gitlab.com/groups/gitlab-examples)
- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml)
-- [Analyze code quality with the Code Climate CLI](code_climate.md)
- **Articles:**
- [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md
index 5659a8c2a2a..4d0ba8bfef3 100644
--- a/doc/ci/examples/code_climate.md
+++ b/doc/ci/examples/code_climate.md
@@ -5,10 +5,10 @@ GitLab CI and Docker.
First, you need GitLab Runner with [docker-in-docker executor][dind].
-Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`:
+Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codequality`:
```yaml
-codeclimate:
+codequality:
image: docker:latest
variables:
DOCKER_DRIVER: overlay
@@ -22,7 +22,7 @@ codeclimate:
paths: [codeclimate.json]
```
-This will create a `codeclimate` job in your CI pipeline and will allow you to
+This will create a `codequality` job in your CI pipeline and will allow you to
download and analyze the report artifact in JSON format.
For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically
diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md
index 756535e28bc..bd0ef39ca62 100644
--- a/doc/development/i18n_guide.md
+++ b/doc/development/i18n_guide.md
@@ -138,6 +138,47 @@ translations. There's no need to generate `.po` files.
Translations that aren't used in the source code anymore will be marked with
`~#`; these can be removed to keep our translation files clutter-free.
+### Validating PO files
+
+To make sure we keep our translation files up to date, there's a linter that is
+running on CI as part of the `static-analysis` job.
+
+To lint the adjustments in PO files locally you can run `rake gettext:lint`.
+
+The linter will take the following into account:
+
+- Valid PO-file syntax
+- Variable usage
+ - Only one unnamed (`%d`) variable, since the order of variables might change
+ in different languages
+ - All variables used in the message-id are used in the translation
+ - There should be no variables used in a translation that aren't in the
+ message-id
+- Errors during translation.
+
+The errors are grouped per file, and per message ID:
+
+```
+Errors in `locale/zh_HK/gitlab.po`:
+ PO-syntax errors
+ SimplePoParser::ParserErrorSyntax error in lines
+ Syntax error in msgctxt
+ Syntax error in msgid
+ Syntax error in msgstr
+ Syntax error in message_line
+ There should be only whitespace until the end of line after the double quote character of a message text.
+ Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
+ SimplePoParser filtered backtrace: SimplePoParser::ParserError
+Errors in `locale/zh_TW/gitlab.po`:
+ 1 pipeline
+ <%d 條流水線> is using unknown variables: [%d]
+ Failure translating to zh_TW with []: too few arguments
+```
+
+In this output the `locale/zh_HK/gitlab.po` has syntax errors.
+The `locale/zh_TW/gitlab.po` has variables that are used in the translation that
+aren't in the message with id `1 pipeline`.
+
## Working with special content
### Interpolation
diff --git a/doc/security/README.md b/doc/security/README.md
index 38706e48ec5..0fea6be8b55 100644
--- a/doc/security/README.md
+++ b/doc/security/README.md
@@ -1,6 +1,7 @@
# Security
- [Password length limits](password_length_limits.md)
+- [Restrict SSH key technologies and minimum length](ssh_keys_restrictions.md)
- [Rack attack](rack_attack.md)
- [Webhooks and insecure internal web services](webhooks.md)
- [Information exclusivity](information_exclusivity.md)
diff --git a/doc/security/img/ssh_keys_restrictions_settings.png b/doc/security/img/ssh_keys_restrictions_settings.png
new file mode 100644
index 00000000000..2e918fd4b3f
--- /dev/null
+++ b/doc/security/img/ssh_keys_restrictions_settings.png
Binary files differ
diff --git a/doc/security/ssh_keys_restrictions.md b/doc/security/ssh_keys_restrictions.md
new file mode 100644
index 00000000000..213fa5bfef5
--- /dev/null
+++ b/doc/security/ssh_keys_restrictions.md
@@ -0,0 +1,19 @@
+# Restrict allowed SSH key technologies and minimum length
+
+`ssh-keygen` allows users to create RSA keys with as few as 768 bits, which
+falls well below recommendations from certain standards groups (such as the US
+NIST). Some organizations deploying GitLab will need to enforce minimum key
+strength, either to satisfy internal security policy or for regulatory
+compliance.
+
+Similarly, certain standards groups recommend using RSA, ECDSA, or ED25519 over
+the older DSA, and administrators may need to limit the allowed SSH key
+algorithms.
+
+GitLab allows you to restrict the allowed SSH key technology as well as specify
+the minimum key length for each technology.
+
+In the Admin area under **Settings** (`/admin/application_settings`), look for
+the "Visibility and Access Controls" area:
+
+![SSH keys restriction admin settings](img/ssh_keys_restrictions_settings.png)
diff --git a/doc/user/project/import/index.md b/doc/user/project/import/index.md
index 2a8728ed96e..67e856a97cd 100644
--- a/doc/user/project/import/index.md
+++ b/doc/user/project/import/index.md
@@ -7,6 +7,7 @@
1. [From Gitea](gitea.md)
1. [From SVN](svn.md)
1. [From ClearCase](clearcase.md)
+1. [From Perforce](perforce.md)
In addition to the specific migration documentation above, you can import any
Git repository via HTTP from the New Project page. Be aware that if the
diff --git a/doc/user/project/import/perforce.md b/doc/user/project/import/perforce.md
new file mode 100644
index 00000000000..aa7508e1e8e
--- /dev/null
+++ b/doc/user/project/import/perforce.md
@@ -0,0 +1,50 @@
+# Migrating from Perforce Helix
+
+[Perforce Helix](https://www.perforce.com/) provides a set of tools which also
+include a centralized, proprietary version control system similar to Git.
+
+## Perforce vs Git
+
+The following list illustrates the main differences between Perforce Helix and
+Git:
+
+1. In general the biggest difference is that Perforce branching is heavyweight
+ compared to Git's lightweight branching. When you create a branch in Perforce,
+ it creates an integration record in their proprietary database for every file
+ in the branch, regardless how many were actually changed. Whereas Git was
+ implemented with a different architecture so that a single SHA acts as a pointer
+ to the state of the whole repo after the changes, making it very easy to branch.
+ This is what made feature branching workflows so easy to adopt with Git.
+1. Also, context switching between branches is much easier in Git. If your manager
+ said 'You need to stop work on that new feature and fix this security
+ vulnerability' you can do so very easily in Git.
+1. Having a complete copy of the project and its history on your local machine
+ means every transaction is superfast and Git provides that. You can branch/merge
+ and experiment in isolation, then clean up your mess before sharing your new
+ cool stuff with everyone.
+1. Git also made code review simple because you could share your changes without
+ merging them to master, whereas Perforce had to implement a Shelving feature on
+ the server so others could review changes before merging.
+
+## Why migrate
+
+Perforce Helix can be difficult to manage both from a user and an admin
+perspective. Migrating to Git/GitLab there is:
+
+- **No licensing costs**, Git is GPL while Perforce Helix is proprietary.
+- **Shorter learning curve**, Git has a big community and a vast number of
+ tutorials to get you started.
+- **Integration with modern tools**, migrating to Git and GitLab you can have
+ an open source end-to-end software development platform with built-in version
+ control, issue tracking, code review, CI/CD, and more.
+
+## How to migrate
+
+Git includes a built-in mechanism (`git p4`) to pull code from Perforce and to
+submit back from Git to Perforce.
+
+Here's a few links to get you started:
+
+- [git-p4 manual page](https://www.kernel.org/pub/software/scm/git/docs/git-p4.html)
+- [git-p4 example usage](https://git.wiki.kernel.org/index.php/Git-p4_Usage)
+- [Git book migration guide](https://git-scm.com/book/en/v2/Git-and-Other-Systems-Migrating-to-Git#_perforce_import)
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index ce4dd4e99d5..6a5d2d40927 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -38,3 +38,4 @@ do.
| `/award :emoji:` | Toggle award for :emoji: |
| `/board_move ~column` | Move issue to column on the board |
| `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue |
+| `/move path/to/project` | Moves issue to another project |
diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb
index f1288c15084..8fb2ac34c32 100644
--- a/features/steps/explore/projects.rb
+++ b/features/steps/explore/projects.rb
@@ -36,13 +36,13 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
end
step 'I should see project "Community" home page' do
- page.within '.navbar-gitlab .title' do
+ page.within '.breadcrumbs .title' do
expect(page).to have_content 'Community'
end
end
step 'I should see project "Internal" home page' do
- page.within '.navbar-gitlab .title' do
+ page.within '.breadcrumbs .title' do
expect(page).to have_content 'Internal'
end
end
diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb
index f6559b6be2f..20edcf75ff1 100644
--- a/features/steps/group/milestones.rb
+++ b/features/steps/group/milestones.rb
@@ -47,7 +47,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
end
step 'I click new milestone button' do
- click_link "New milestone"
+ page.within('.breadcrumbs') do
+ click_link "New milestone"
+ end
end
step 'I press create mileston button' do
diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb
index 5cd9bd38c9d..1a18f1d7065 100644
--- a/features/steps/project/active_tab.rb
+++ b/features/steps/project/active_tab.rb
@@ -22,25 +22,25 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
step 'I click the "Edit Project"' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
click_link('Edit Project')
end
end
step 'I click the "Integrations" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
click_link('Integrations')
end
end
step 'I click the "Repository" tab' do
- page.within '.sub-nav' do
+ page.within '.sidebar-top-level-items > .active' do
click_link('Repository')
end
end
step 'I click the "Activity" tab' do
- page.within '.sub-nav' do
+ page.within '.sidebar-top-level-items > .active' do
click_link('Activity')
end
end
@@ -72,7 +72,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
step 'I click the "Branches" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
click_link('Branches')
end
end
@@ -82,7 +82,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
step 'I click the "Charts" tab' do
- page.within '.sub-nav' do
+ page.within('.sidebar-top-level-items > .active') do
click_link('Charts')
end
end
@@ -102,13 +102,13 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
# Sub Tabs: Issues
step 'I click the "Milestones" sub tab' do
- page.within('.sub-nav') do
+ page.within('.nav-sidebar') do
click_link('Milestones')
end
end
step 'I click the "Labels" sub tab' do
- page.within('.sub-nav') do
+ page.within('.nav-sidebar') do
click_link('Labels')
end
end
diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb
index dd4dff7f7a9..3b8d9af96c1 100644
--- a/features/steps/project/fork.rb
+++ b/features/steps/project/fork.rb
@@ -36,7 +36,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I goto the Merge Requests page' do
- page.within '.layout-nav' do
+ page.within '.nav-sidebar' do
click_link "Merge Requests"
end
end
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 2deef9036d3..f7dd4fc21e9 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -62,7 +62,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "New issue"' do
- page.within '#content-body' do
+ page.within '.breadcrumbs' do
page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
end
end
@@ -168,6 +168,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
author: project.users.first,
description: "# Description header"
)
+ wait_for_requests
end
step 'project "Shop" have "Tweet control" open issue' do
diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb
index fe94eb03acd..307902a887e 100644
--- a/features/steps/project/issues/milestones.rb
+++ b/features/steps/project/issues/milestones.rb
@@ -16,7 +16,9 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
end
step 'I click link "New Milestone"' do
- click_link "New milestone"
+ page.within('.breadcrumbs') do
+ click_link "New milestone"
+ end
end
step 'I submit new milestone "v2.3"' do
diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb
index 7254fbc2e4e..3c3bffd7223 100644
--- a/features/steps/project/merge_requests.rb
+++ b/features/steps/project/merge_requests.rb
@@ -14,7 +14,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I click link "New Merge Request"' do
- page.within '#content-body' do
+ page.within '.breadcrumbs' do
page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
end
end
diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb
index bb69c0d6e99..9705470738e 100644
--- a/features/steps/project/pages.rb
+++ b/features/steps/project/pages.rb
@@ -23,13 +23,13 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps
end
step 'I should see the "Pages" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
expect(page).to have_link('Pages')
end
end
step 'I should not see the "Pages" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
expect(page).not_to have_link('Pages')
end
end
diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb
index a7d3352b8c4..b2d08515e77 100644
--- a/features/steps/project/project_milestone.rb
+++ b/features/steps/project/project_milestone.rb
@@ -55,7 +55,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps
end
step 'I click link "Labels"' do
- page.within('.layout-nav .nav-links') do
+ page.within('.nav-sidebar') do
page.find(:xpath, "//a[@href='#tab-labels']").click
end
end
diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb
index 53a2463af53..100e674abed 100644
--- a/features/steps/project/redirects.rb
+++ b/features/steps/project/redirects.rb
@@ -18,7 +18,7 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
step 'I should see project "Community" home page' do
Gitlab.config.gitlab.should_receive(:host).and_return("www.example.com")
- page.within '.navbar-gitlab .title' do
+ page.within '.breadcrumbs .title' do
expect(page).to have_content 'Community'
end
end
diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb
index b0407d3f07d..96b7ba7549f 100644
--- a/features/steps/project/snippets.rb
+++ b/features/steps/project/snippets.rb
@@ -23,7 +23,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
end
step 'I click link "New snippet"' do
- page.within '#content-body' do
+ page.within '.breadcrumbs' do
first(:link, "New snippet").click
end
end
diff --git a/features/steps/shared/active_tab.rb b/features/steps/shared/active_tab.rb
index af5db05e9e8..2bb21a798aa 100644
--- a/features/steps/shared/active_tab.rb
+++ b/features/steps/shared/active_tab.rb
@@ -7,11 +7,11 @@ module SharedActiveTab
end
def ensure_active_main_tab(content)
- expect(find('.layout-nav li.active')).to have_content(content)
+ expect(find('.sidebar-top-level-items > li.active')).to have_content(content)
end
def ensure_active_sub_tab(content)
- expect(find('.sub-nav li.active')).to have_content(content)
+ expect(find('.sidebar-sub-level-items > li.active')).to have_content(content)
end
def ensure_active_sub_nav(content)
@@ -19,11 +19,11 @@ module SharedActiveTab
end
step 'no other main tabs should be active' do
- expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1)
+ expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1)
end
step 'no other sub tabs should be active' do
- expect(page).to have_selector('.sub-nav li.active', count: 1)
+ expect(page).to have_selector('.sidebar-sub-level-items > li.active', count: 1)
end
step 'no other sub navs should be active' do
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 492da38355c..0cd7b506a95 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -137,7 +137,7 @@ module SharedNote
step 'The comment with the header should not have an ID' do
page.within(".note-body > .note-text") do
- expect(page).to have_content("Comment with a header")
+ expect(page).to have_content("Comment with a header")
expect(page).not_to have_css("#comment-with-a-header")
end
end
@@ -150,15 +150,20 @@ module SharedNote
note.find('.js-note-edit').click
end
+ page.find('.current-note-edit-form textarea')
+
page.within(".current-note-edit-form") do
fill_in 'note[note]', with: '+1 Awesome!'
click_button 'Save comment'
end
+ wait_for_requests
end
step 'I should see +1 in the description' do
page.within(".note") do
expect(page).to have_content("+1 Awesome!")
end
+
+ wait_for_requests
end
end
diff --git a/features/steps/shared/project_tab.rb b/features/steps/shared/project_tab.rb
index 901f7f76ee9..5a516ee33bc 100644
--- a/features/steps/shared/project_tab.rb
+++ b/features/steps/shared/project_tab.rb
@@ -5,7 +5,7 @@ module SharedProjectTab
include SharedActiveTab
step 'the active main tab should be Project' do
- ensure_active_main_tab('Project')
+ ensure_active_main_tab('Overview')
end
step 'the active main tab should be Repository' do
@@ -53,7 +53,7 @@ module SharedProjectTab
end
step 'the active sub tab should be Home' do
- ensure_active_sub_tab('Home')
+ ensure_active_sub_tab('Details')
end
step 'the active sub tab should be Activity' do
diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb
index 4fa9b2b2494..374b611f55e 100644
--- a/lib/api/access_requests.rb
+++ b/lib/api/access_requests.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: "The #{source_type} ID"
end
- resource source_type.pluralize, requirements: { id: %r{[^/]+} } do
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Gets a list of access requests for a #{source_type}." do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::AccessRequester
diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb
index 8e3851640da..c3d93996816 100644
--- a/lib/api/award_emoji.rb
+++ b/lib/api/award_emoji.rb
@@ -12,7 +12,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
AWARDABLES.each do |awardable_params|
awardable_string = awardable_params[:type].pluralize
awardable_id_string = "#{awardable_params[:type]}_#{awardable_params[:find_by]}"
diff --git a/lib/api/boards.rb b/lib/api/boards.rb
index 0d11c5fc971..366b0dc9a6f 100644
--- a/lib/api/boards.rb
+++ b/lib/api/boards.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all project boards' do
detail 'This feature was introduced in 8.13'
success Entities::Board
diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb
index 485b680cd5f..78e889a4c35 100644
--- a/lib/api/commit_statuses.rb
+++ b/lib/api/commit_statuses.rb
@@ -5,7 +5,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
include PaginationParams
before { authenticate! }
diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb
index f405c341398..281269b1190 100644
--- a/lib/api/deploy_keys.rb
+++ b/lib/api/deploy_keys.rb
@@ -17,7 +17,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of the project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before { authorize_admin_project }
desc "Get a specific project's deploy keys" do
diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb
index 46b936897f6..1efee9a1324 100644
--- a/lib/api/deployments.rb
+++ b/lib/api/deployments.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all deployments of the project' do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::Deployment
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 803b48dd88a..9df9a515990 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1,11 +1,11 @@
module API
module Entities
class UserSafe < Grape::Entity
- expose :name, :username
+ expose :id, :name, :username
end
class UserBasic < UserSafe
- expose :id, :state
+ expose :state
expose :avatar_url do |user, options|
user.avatar_url(only_path: false)
end
diff --git a/lib/api/environments.rb b/lib/api/environments.rb
index e33269f9483..5c63ec028d9 100644
--- a/lib/api/environments.rb
+++ b/lib/api/environments.rb
@@ -9,7 +9,7 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all environments of the project' do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::Environment
diff --git a/lib/api/events.rb b/lib/api/events.rb
index dabdf579119..b0713ff1d54 100644
--- a/lib/api/events.rb
+++ b/lib/api/events.rb
@@ -67,7 +67,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "List a Project's visible events" do
success Entities::Event
end
diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb
index b85eb59dc0a..93fa0b95857 100644
--- a/lib/api/group_milestones.rb
+++ b/lib/api/group_milestones.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of group milestones' do
success Entities::Milestone
end
diff --git a/lib/api/group_variables.rb b/lib/api/group_variables.rb
index 25152f30998..92800ce6450 100644
--- a/lib/api/group_variables.rb
+++ b/lib/api/group_variables.rb
@@ -9,7 +9,7 @@ module API
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get group-level variables' do
success Entities::Variable
end
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index 8c494a54329..31a918eda60 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -89,7 +89,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Update a group. Available only for users who can administrate groups.' do
success Entities::Group
end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index 0297023226f..e4c2c390853 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -81,7 +81,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of group issues' do
success Entities::IssueBasic
end
@@ -108,7 +108,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
include TimeTrackingEndpoints
desc 'Get a list of project issues' do
diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb
index a40018b214e..5bab96398fd 100644
--- a/lib/api/jobs.rb
+++ b/lib/api/jobs.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
params :optional_scope do
optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show',
diff --git a/lib/api/labels.rb b/lib/api/labels.rb
index c0cf618ee8d..e41a1720ac1 100644
--- a/lib/api/labels.rb
+++ b/lib/api/labels.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all labels of the project' do
success Entities::Label
end
diff --git a/lib/api/members.rb b/lib/api/members.rb
index a5d3d7f25a0..22e4bdead41 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: "The #{source_type} ID"
end
- resource source_type.pluralize, requirements: { id: %r{[^/]+} } do
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Gets a list of group or project members viewable by the authenticated user.' do
success Entities::Member
end
diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb
index 4b79eac2b8b..c3affcc6c6b 100644
--- a/lib/api/merge_request_diffs.rb
+++ b/lib/api/merge_request_diffs.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of merge request diff versions' do
detail 'This feature was introduced in GitLab 8.12.'
success Entities::MergeRequestDiff
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index eec8d9357aa..7bcbf9f20ff 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -72,7 +72,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
include TimeTrackingEndpoints
helpers do
diff --git a/lib/api/notes.rb b/lib/api/notes.rb
index e116448c15b..d6e7203adaf 100644
--- a/lib/api/notes.rb
+++ b/lib/api/notes.rb
@@ -9,7 +9,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
NOTEABLE_TYPES.each do |noteable_type|
noteables_str = noteable_type.to_s.underscore.pluralize
diff --git a/lib/api/notification_settings.rb b/lib/api/notification_settings.rb
index 5d113c94b22..bcc0833aa5c 100644
--- a/lib/api/notification_settings.rb
+++ b/lib/api/notification_settings.rb
@@ -54,7 +54,7 @@ module API
params do
requires :id, type: String, desc: "The #{source_type} ID"
end
- resource source_type.pluralize, requirements: { id: %r{[^/]+} } do
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Get #{source_type} level notification level settings, defaults to Global" do
detail 'This feature was introduced in GitLab 8.12'
success Entities::NotificationSetting
diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb
index e3123ef4e2d..ef01cbc7875 100644
--- a/lib/api/pipeline_schedules.rb
+++ b/lib/api/pipeline_schedules.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all pipeline schedules' do
success Entities::PipelineSchedule
end
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
index e505cae3992..74b3376a1f3 100644
--- a/lib/api/pipelines.rb
+++ b/lib/api/pipelines.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all Pipelines of the project' do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::PipelineBasic
diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb
index 5b457bbe639..86066e2b58f 100644
--- a/lib/api/project_hooks.rb
+++ b/lib/api/project_hooks.rb
@@ -24,7 +24,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get project hooks' do
success Entities::ProjectHook
end
diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb
index 451998c726a..0cb209a02d0 100644
--- a/lib/api/project_milestones.rb
+++ b/lib/api/project_milestones.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of project milestones' do
success Entities::Milestone
end
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index 704e8c6718d..2ccda1c1aa1 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
def handle_project_member_errors(errors)
if errors[:project_access].any?
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 78d900984ac..4845242a173 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -95,7 +95,7 @@ module API
end
end
- resource :users, requirements: { user_id: %r{[^/]+} } do
+ resource :users, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a user projects' do
success Entities::BasicProjectDetails
end
@@ -183,7 +183,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a single project' do
success Entities::ProjectWithAccess
end
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 14d2bff9cb5..2255fb1b70d 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -9,7 +9,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
def handle_project_member_errors(errors)
if errors[:project_access].any?
diff --git a/lib/api/runners.rb b/lib/api/runners.rb
index 68c2120cc15..1ea9a7918d7 100644
--- a/lib/api/runners.rb
+++ b/lib/api/runners.rb
@@ -87,7 +87,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before { authorize_admin_project }
desc 'Get runners available for project' do
diff --git a/lib/api/services.rb b/lib/api/services.rb
index ff9ddd44439..2cbd0517dc3 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -601,7 +601,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before { authenticate! }
before { authorize_admin_project }
@@ -691,7 +691,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Trigger a slash command for #{service_slug}" do
detail 'Added in GitLab 8.13'
end
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index 667ba468ce6..851b226e9e5 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -122,6 +122,13 @@ module API
optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.'
optional :polling_interval_multiplier, type: BigDecimal, desc: 'Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling.'
+ ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ optional :"#{type}_key_restriction",
+ type: Integer,
+ values: KeyRestrictionValidator.supported_key_restrictions(type),
+ desc: "Restrictions on the complexity of uploaded #{type.upcase} keys. A value of #{ApplicationSetting::FORBIDDEN_KEY_VALUE} disables all #{type.upcase} keys."
+ end
+
optional(*::ApplicationSettingsHelper.visible_attributes)
at_least_one_of(*::ApplicationSettingsHelper.visible_attributes)
end
diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb
index 91567909998..b3e1e23031a 100644
--- a/lib/api/subscriptions.rb
+++ b/lib/api/subscriptions.rb
@@ -12,7 +12,7 @@ module API
requires :id, type: String, desc: 'The ID of a project'
requires :subscribable_id, type: String, desc: 'The ID of a resource'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
subscribable_types.each do |type, finder|
type_singularized = type.singularize
entity_class = Entities.const_get(type_singularized.camelcase)
diff --git a/lib/api/todos.rb b/lib/api/todos.rb
index 55191169dd4..ffccfebe752 100644
--- a/lib/api/todos.rb
+++ b/lib/api/todos.rb
@@ -12,7 +12,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
ISSUABLE_TYPES.each do |type, finder|
type_id_str = "#{type.singularize}_iid".to_sym
diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb
index c9fee7e5193..dd6801664b1 100644
--- a/lib/api/triggers.rb
+++ b/lib/api/triggers.rb
@@ -5,7 +5,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Trigger a GitLab project pipeline' do
success Entities::Pipeline
end
diff --git a/lib/api/variables.rb b/lib/api/variables.rb
index da71787abab..d08876ae1b9 100644
--- a/lib/api/variables.rb
+++ b/lib/api/variables.rb
@@ -9,7 +9,7 @@ module API
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get project variables' do
success Entities::Variable
end
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 1790f380c33..3fd81759d25 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -50,10 +50,6 @@ module Gitlab
# Avoid resource intensive login checks if password is not provided
return unless password.present?
- # Nothing to do here if internal auth is disabled and LDAP is
- # not configured
- return unless current_application_settings.password_authentication_enabled? || Gitlab::LDAP::Config.enabled?
-
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
user = User.by_login(login)
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 554e40dc8a6..8709f82bcc4 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -250,11 +250,17 @@ module Gitlab
branch_names + tag_names
end
+ def delete_all_refs_except(prefixes)
+ delete_refs(*all_ref_names_except(prefixes))
+ end
+
# Returns an Array of all ref names, except when it's matching pattern
#
# regexp - The pattern for ref names we don't want
- def all_ref_names_except(regexp)
- rugged.references.reject { |ref| ref.name =~ regexp }.map(&:name)
+ def all_ref_names_except(prefixes)
+ rugged.references.reject do |ref|
+ prefixes.any? { |p| ref.name.start_with?(p) }
+ end.map(&:name)
end
# Discovers the default branch based on the repository's available branches
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index 3e8b83c0f90..62d1ecae676 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -35,6 +35,7 @@ module Gitlab
def check(cmd, changes)
check_protocol!
+ check_valid_actor!
check_active_user!
check_project_accessibility!
check_project_moved!
@@ -70,6 +71,14 @@ module Gitlab
private
+ def check_valid_actor!
+ return unless actor.is_a?(Key)
+
+ unless actor.valid?
+ raise UnauthorizedError, "Your SSH key #{actor.errors[:key].first}."
+ end
+ end
+
def check_protocol!
unless protocol_allowed?
raise UnauthorizedError, "Git access over #{protocol.upcase} is not allowed"
diff --git a/lib/gitlab/i18n/metadata_entry.rb b/lib/gitlab/i18n/metadata_entry.rb
new file mode 100644
index 00000000000..35d57459a3d
--- /dev/null
+++ b/lib/gitlab/i18n/metadata_entry.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module I18n
+ class MetadataEntry
+ attr_reader :entry_data
+
+ def initialize(entry_data)
+ @entry_data = entry_data
+ end
+
+ def expected_plurals
+ return nil unless plural_information
+
+ plural_information['nplurals'].to_i
+ end
+
+ private
+
+ def plural_information
+ return @plural_information if defined?(@plural_information)
+
+ if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') }
+ @plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb
new file mode 100644
index 00000000000..2e02787a4f4
--- /dev/null
+++ b/lib/gitlab/i18n/po_linter.rb
@@ -0,0 +1,216 @@
+require 'simple_po_parser'
+
+module Gitlab
+ module I18n
+ class PoLinter
+ attr_reader :po_path, :translation_entries, :metadata_entry, :locale
+
+ VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze
+
+ def initialize(po_path, locale = I18n.locale.to_s)
+ @po_path = po_path
+ @locale = locale
+ end
+
+ def errors
+ @errors ||= validate_po
+ end
+
+ def validate_po
+ if parse_error = parse_po
+ return 'PO-syntax errors' => [parse_error]
+ end
+
+ validate_entries
+ end
+
+ def parse_po
+ entries = SimplePoParser.parse(po_path)
+
+ # The first entry is the metadata entry if there is one.
+ # This is an entry when empty `msgid`
+ if entries.first[:msgid].empty?
+ @metadata_entry = Gitlab::I18n::MetadataEntry.new(entries.shift)
+ else
+ return 'Missing metadata entry.'
+ end
+
+ @translation_entries = entries.map do |entry_data|
+ Gitlab::I18n::TranslationEntry.new(entry_data, metadata_entry.expected_plurals)
+ end
+
+ nil
+ rescue SimplePoParser::ParserError => e
+ @translation_entries = []
+ e.message
+ end
+
+ def validate_entries
+ errors = {}
+
+ translation_entries.each do |entry|
+ errors_for_entry = validate_entry(entry)
+ errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any?
+ end
+
+ errors
+ end
+
+ def validate_entry(entry)
+ errors = []
+
+ validate_flags(errors, entry)
+ validate_variables(errors, entry)
+ validate_newlines(errors, entry)
+ validate_number_of_plurals(errors, entry)
+ validate_unescaped_chars(errors, entry)
+
+ errors
+ end
+
+ def validate_unescaped_chars(errors, entry)
+ if entry.msgid_contains_unescaped_chars?
+ errors << 'contains unescaped `%`, escape it using `%%`'
+ end
+
+ if entry.plural_id_contains_unescaped_chars?
+ errors << 'plural id contains unescaped `%`, escape it using `%%`'
+ end
+
+ if entry.translations_contain_unescaped_chars?
+ errors << 'translation contains unescaped `%`, escape it using `%%`'
+ end
+ end
+
+ def validate_number_of_plurals(errors, entry)
+ return unless metadata_entry&.expected_plurals
+ return unless entry.translated?
+
+ if entry.has_plural? && entry.all_translations.size != metadata_entry.expected_plurals
+ errors << "should have #{metadata_entry.expected_plurals} "\
+ "#{'translations'.pluralize(metadata_entry.expected_plurals)}"
+ end
+ end
+
+ def validate_newlines(errors, entry)
+ if entry.msgid_contains_newlines?
+ errors << 'is defined over multiple lines, this breaks some tooling.'
+ end
+
+ if entry.plural_id_contains_newlines?
+ errors << 'plural is defined over multiple lines, this breaks some tooling.'
+ end
+
+ if entry.translations_contain_newlines?
+ errors << 'has translations defined over multiple lines, this breaks some tooling.'
+ end
+ end
+
+ def validate_variables(errors, entry)
+ if entry.has_singular_translation?
+ validate_variables_in_message(errors, entry.msgid, entry.singular_translation)
+ end
+
+ if entry.has_plural?
+ entry.plural_translations.each do |translation|
+ validate_variables_in_message(errors, entry.plural_id, translation)
+ end
+ end
+ end
+
+ def validate_variables_in_message(errors, message_id, message_translation)
+ message_id = join_message(message_id)
+ required_variables = message_id.scan(VARIABLE_REGEX)
+
+ validate_unnamed_variables(errors, required_variables)
+ validate_translation(errors, message_id, required_variables)
+ validate_variable_usage(errors, message_translation, required_variables)
+ end
+
+ def validate_translation(errors, message_id, used_variables)
+ variables = fill_in_variables(used_variables)
+
+ begin
+ Gitlab::I18n.with_locale(locale) do
+ translated = if message_id.include?('|')
+ FastGettext::Translation.s_(message_id)
+ else
+ FastGettext::Translation._(message_id)
+ end
+
+ translated % variables
+ end
+
+ # `sprintf` could raise an `ArgumentError` when invalid passing something
+ # other than a Hash when using named variables
+ #
+ # `sprintf` could raise `TypeError` when passing a wrong type when using
+ # unnamed variables
+ #
+ # FastGettext::Translation could raise `RuntimeError` (raised as a string),
+ # or as subclassess `NoTextDomainConfigured` & `InvalidFormat`
+ #
+ # `FastGettext::Translation` could raise `ArgumentError` as subclassess
+ # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter`
+ rescue ArgumentError, TypeError, RuntimeError => e
+ errors << "Failure translating to #{locale} with #{variables}: #{e.message}"
+ end
+ end
+
+ def fill_in_variables(variables)
+ if variables.empty?
+ []
+ elsif variables.any? { |variable| unnamed_variable?(variable) }
+ variables.map do |variable|
+ variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string
+ end
+ else
+ variables.inject({}) do |hash, variable|
+ variable_name = variable[/\w+/]
+ hash[variable_name] = Gitlab::Utils.random_string
+ hash
+ end
+ end
+ end
+
+ def validate_unnamed_variables(errors, variables)
+ if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) }
+ errors << 'is combining multiple unnamed variables'
+ end
+ end
+
+ def validate_variable_usage(errors, translation, required_variables)
+ translation = join_message(translation)
+
+ # We don't need to validate when the message is empty.
+ # In this case we fall back to the default, which has all the the
+ # required variables.
+ return if translation.empty?
+
+ found_variables = translation.scan(VARIABLE_REGEX)
+
+ missing_variables = required_variables - found_variables
+ if missing_variables.any?
+ errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]"
+ end
+
+ unknown_variables = found_variables - required_variables
+ if unknown_variables.any?
+ errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]"
+ end
+ end
+
+ def unnamed_variable?(variable_name)
+ !variable_name.start_with?('%{')
+ end
+
+ def validate_flags(errors, entry)
+ errors << "is marked #{entry.flag}" if entry.flag
+ end
+
+ def join_message(message)
+ Array(message).join
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb
new file mode 100644
index 00000000000..e6c95afca7e
--- /dev/null
+++ b/lib/gitlab/i18n/translation_entry.rb
@@ -0,0 +1,92 @@
+module Gitlab
+ module I18n
+ class TranslationEntry
+ PERCENT_REGEX = /(?:^|[^%])%(?!{\w*}|[a-z%])/.freeze
+
+ attr_reader :nplurals, :entry_data
+
+ def initialize(entry_data, nplurals)
+ @entry_data = entry_data
+ @nplurals = nplurals
+ end
+
+ def msgid
+ entry_data[:msgid]
+ end
+
+ def plural_id
+ entry_data[:msgid_plural]
+ end
+
+ def has_plural?
+ plural_id.present?
+ end
+
+ def singular_translation
+ all_translations.first if has_singular_translation?
+ end
+
+ def all_translations
+ @all_translations ||= entry_data.fetch_values(*translation_keys)
+ .reject(&:empty?)
+ end
+
+ def translated?
+ all_translations.any?
+ end
+
+ def plural_translations
+ return [] unless has_plural?
+ return [] unless translated?
+
+ @plural_translations ||= if has_singular_translation?
+ all_translations.drop(1)
+ else
+ all_translations
+ end
+ end
+
+ def flag
+ entry_data[:flag]
+ end
+
+ def has_singular_translation?
+ nplurals > 1 || !has_plural?
+ end
+
+ def msgid_contains_newlines?
+ msgid.is_a?(Array)
+ end
+
+ def plural_id_contains_newlines?
+ plural_id.is_a?(Array)
+ end
+
+ def translations_contain_newlines?
+ all_translations.any? { |translation| translation.is_a?(Array) }
+ end
+
+ def msgid_contains_unescaped_chars?
+ contains_unescaped_chars?(msgid)
+ end
+
+ def plural_id_contains_unescaped_chars?
+ contains_unescaped_chars?(plural_id)
+ end
+
+ def translations_contain_unescaped_chars?
+ all_translations.any? { |translation| contains_unescaped_chars?(translation) }
+ end
+
+ def contains_unescaped_chars?(string)
+ string =~ PERCENT_REGEX
+ end
+
+ private
+
+ def translation_keys
+ @translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb
deleted file mode 100644
index d9a79f7c291..00000000000
--- a/lib/gitlab/key_fingerprint.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-module Gitlab
- class KeyFingerprint
- attr_reader :key, :ssh_key
-
- # Unqualified MD5 fingerprint for compatibility
- delegate :fingerprint, to: :ssh_key, allow_nil: true
-
- def initialize(key)
- @key = key
-
- @ssh_key =
- begin
- Net::SSH::KeyFactory.load_data_public_key(key)
- rescue Net::SSH::Exception, NotImplementedError
- end
- end
-
- def valid?
- ssh_key.present?
- end
-
- def type
- return unless valid?
-
- parts = ssh_key.ssh_type.split('-')
- parts.shift if parts[0] == 'ssh'
-
- parts[0].upcase
- end
-
- def bits
- return unless valid?
-
- case type
- when 'RSA'
- ssh_key.n.num_bits
- when 'DSS', 'DSA'
- ssh_key.p.num_bits
- when 'ECDSA'
- ssh_key.group.order.num_bits
- when 'ED25519'
- 256
- else
- raise "Unsupported key type: #{type}"
- end
- end
- end
-end
diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb
index f6bdd6cf0fe..159d0e7952e 100644
--- a/lib/gitlab/sentry.rb
+++ b/lib/gitlab/sentry.rb
@@ -9,6 +9,8 @@ module Gitlab
def self.context(current_user = nil)
return unless self.enabled?
+ Raven.tags_context(locale: I18n.locale)
+
if current_user
Raven.user_context(
id: current_user.id,
diff --git a/lib/gitlab/ssh_public_key.rb b/lib/gitlab/ssh_public_key.rb
new file mode 100644
index 00000000000..89ca1298120
--- /dev/null
+++ b/lib/gitlab/ssh_public_key.rb
@@ -0,0 +1,71 @@
+module Gitlab
+ class SSHPublicKey
+ Technology = Struct.new(:name, :key_class, :supported_sizes)
+
+ Technologies = [
+ Technology.new(:rsa, OpenSSL::PKey::RSA, [1024, 2048, 3072, 4096]),
+ Technology.new(:dsa, OpenSSL::PKey::DSA, [1024, 2048, 3072]),
+ Technology.new(:ecdsa, OpenSSL::PKey::EC, [256, 384, 521]),
+ Technology.new(:ed25519, Net::SSH::Authentication::ED25519::PubKey, [256])
+ ].freeze
+
+ def self.technology(name)
+ Technologies.find { |tech| tech.name.to_s == name.to_s }
+ end
+
+ def self.technology_for_key(key)
+ Technologies.find { |tech| key.is_a?(tech.key_class) }
+ end
+
+ def self.supported_sizes(name)
+ technology(name)&.supported_sizes
+ end
+
+ attr_reader :key_text, :key
+
+ # Unqualified MD5 fingerprint for compatibility
+ delegate :fingerprint, to: :key, allow_nil: true
+
+ def initialize(key_text)
+ @key_text = key_text
+
+ @key =
+ begin
+ Net::SSH::KeyFactory.load_data_public_key(key_text)
+ rescue StandardError, NotImplementedError
+ end
+ end
+
+ def valid?
+ key.present?
+ end
+
+ def type
+ technology.name if valid?
+ end
+
+ def bits
+ return unless valid?
+
+ case type
+ when :rsa
+ key.n.num_bits
+ when :dsa
+ key.p.num_bits
+ when :ecdsa
+ key.group.order.num_bits
+ when :ed25519
+ 256
+ else
+ raise "Unsupported key type: #{type}"
+ end
+ end
+
+ private
+
+ def technology
+ @technology ||=
+ self.class.technology_for_key(key) || raise("Unsupported key type: #{key.class}")
+ end
+ end
+end
diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index 9670c93759e..abb3d3a02c3 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -42,5 +42,9 @@ module Gitlab
'No'
end
end
+
+ def random_string
+ Random.rand(Float::MAX.to_i).to_s(36)
+ end
end
end
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index a362a3a0bc6..e5ad9b5a40c 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -35,10 +35,7 @@ module Gitlab
when 'git_receive_pack'
Gitlab::GitalyClient.feature_enabled?(:post_receive_pack)
when 'git_upload_pack'
- Gitlab::GitalyClient.feature_enabled?(
- :post_upload_pack,
- status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT
- )
+ true
when 'info_refs'
true
else
diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake
index b48e4dce445..e1491f29b5e 100644
--- a/lib/tasks/gettext.rake
+++ b/lib/tasks/gettext.rake
@@ -19,4 +19,44 @@ namespace :gettext do
Rake::Task['gettext:pack'].invoke
Rake::Task['gettext:po_to_json'].invoke
end
+
+ desc 'Lint all po files in `locale/'
+ task lint: :environment do
+ FastGettext.silence_errors
+ files = Dir.glob(Rails.root.join('locale/*/gitlab.po'))
+
+ linters = files.map do |file|
+ locale = File.basename(File.dirname(file))
+
+ Gitlab::I18n::PoLinter.new(file, locale)
+ end
+
+ pot_file = Rails.root.join('locale/gitlab.pot')
+ linters.unshift(Gitlab::I18n::PoLinter.new(pot_file))
+
+ failed_linters = linters.select { |linter| linter.errors.any? }
+
+ if failed_linters.empty?
+ puts 'All PO files are valid.'
+ else
+ failed_linters.each do |linter|
+ report_errors_for_file(linter.po_path, linter.errors)
+ end
+
+ raise "Not all PO-files are valid: #{failed_linters.map(&:po_path).to_sentence}"
+ end
+ end
+
+ def report_errors_for_file(file, errors_for_file)
+ puts "Errors in `#{file}`:"
+
+ errors_for_file.each do |message_id, errors|
+ puts " #{message_id}"
+ errors.each do |error|
+ spaces = ' ' * 4
+ error = error.lines.join("#{spaces}")
+ puts "#{spaces}#{error}"
+ end
+ end
+ end
end
diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po
index 0ac591d4927..84232be601e 100644
--- a/locale/en/gitlab.po
+++ b/locale/en/gitlab.po
@@ -82,6 +82,9 @@ msgstr ""
msgid "Add new directory"
msgstr ""
+msgid "All"
+msgstr ""
+
msgid "Archived project! Repository is read-only"
msgstr ""
@@ -222,6 +225,9 @@ msgstr ""
msgid "CiStatus|running"
msgstr ""
+msgid "Comments"
+msgstr ""
+
msgid "Commit"
msgid_plural "Commits"
msgstr[0] ""
@@ -394,6 +400,24 @@ msgstr ""
msgid "Edit Pipeline Schedule %{id}"
msgstr ""
+msgid "EventFilterBy|Filter by all"
+msgstr ""
+
+msgid "EventFilterBy|Filter by comments"
+msgstr ""
+
+msgid "EventFilterBy|Filter by issue events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by merge events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by push events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by team"
+msgstr ""
+
msgid "Every day (at 4:00am)"
msgstr ""
@@ -489,6 +513,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "Issue events"
+msgstr ""
+
msgid "Jobs for last month"
msgstr ""
@@ -518,6 +545,12 @@ msgstr ""
msgid "Last commit"
msgstr ""
+msgid "LastPushEvent|You pushed to"
+msgstr ""
+
+msgid "LastPushEvent|at"
+msgstr ""
+
msgid "Learn more in the"
msgstr ""
@@ -538,6 +571,9 @@ msgstr[1] ""
msgid "Median"
msgstr ""
+msgid "Merge events"
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr ""
@@ -741,6 +777,9 @@ msgstr ""
msgid "Pipeline|with stages"
msgstr ""
+msgid "Project"
+msgstr ""
+
msgid "Project '%{project_name}' queued for deletion."
msgstr ""
@@ -774,6 +813,9 @@ msgstr ""
msgid "Project home"
msgstr ""
+msgid "ProjectActivityRSS|Subscribe"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr ""
@@ -795,6 +837,9 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
+msgid "Push events"
+msgstr ""
+
msgid "Read more"
msgstr ""
@@ -925,6 +970,9 @@ msgstr ""
msgid "Target Branch"
msgstr ""
+msgid "Team"
+msgstr ""
+
msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
msgstr ""
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5a1db208d5a..2b7c6f7ad33 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3,7 +3,6 @@
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
-#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po
index 4037ff731a2..670ac2d9684 100644
--- a/locale/ja/gitlab.po
+++ b/locale/ja/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 個のパイプライン"
+msgstr[0] "%d 個のパイプライン"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "CIについてのグラフ"
diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po
index 125ca220c81..df850115222 100644
--- a/locale/ko/gitlab.po
+++ b/locale/ko/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 파이프라인"
+msgstr[0] "%d 파이프라인"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "지속적인 통합에 관한 그래프 모음"
diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po
index b25234da030..eb607acf1f4 100644
--- a/locale/zh_CN/gitlab.po
+++ b/locale/zh_CN/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 条流水线"
+msgstr[0] "%d 条流水线"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "持续集成数据图"
diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po
index 8a3a69a0ac0..74c7b464091 100644
--- a/locale/zh_HK/gitlab.po
+++ b/locale/zh_HK/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 條流水線"
+msgstr[0] "%d 條流水線"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "相關持續集成的圖像集合"
diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po
index 91c1cc6bf66..1fc6b79187f 100644
--- a/locale/zh_TW/gitlab.po
+++ b/locale/zh_TW/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 條流水線"
+msgstr[0] "%d 條流水線"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "持續整合 (CI) 相關的圖表"
@@ -1208,16 +1208,16 @@ msgid "Withdraw Access Request"
msgstr "取消權限申請"
msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
-msgstr "即將要刪除 %{group_name}。被刪除的群組完全無法救回來喔!真的「100%確定」要這麼做嗎?"
+msgstr "即將要刪除 %{group_name}。被刪除的群組無法復原!真的「確定」要這麼做嗎?"
msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
-msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案完全無法救回來喔!真的「100%確定」要這麼做嗎?"
+msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案無法復原!真的「確定」要這麼做嗎?"
msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
-msgstr "將要刪除本分支專案與主幹的所有關聯 (fork relationship) 。 %{forked_from_project} 真的「100%確定」要這麼做嗎?"
+msgstr "將要刪除本分支專案與主幹 %{forked_from_project} 的所有關聯。 真的「確定」要這麼做嗎?"
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
-msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"
+msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「確定」要這麼做嗎?"
msgid "You can only add files when you are on a branch"
msgstr "只能在分支 (branch) 上建立檔案"
diff --git a/package.json b/package.json
index 99704c07849..feae6ca9748 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"vue-loader": "^11.3.4",
"vue-resource": "^1.3.4",
"vue-template-compiler": "^2.2.6",
+ "vuex": "^2.3.1",
"webpack": "^3.5.5",
"webpack-bundle-analyzer": "^2.8.2",
"webpack-stats-plugin": "^0.1.5"
diff --git a/public/404.html b/public/404.html
index 4db72be6f8c..08f328da542 100644
--- a/public/404.html
+++ b/public/404.html
@@ -72,8 +72,9 @@
404
</h1>
<div class="container">
- <h3>The page you're looking for could not be found.</h3>
+ <h3>The page could not be found or you don't have permission to view it.</h3>
<hr />
+ <p>The resource that you are attempting to access does not exist or you don't have the necessary permissions to view it.</p>
<p>Make sure the address is correct and that the page hasn't moved.</p>
<p>Please contact your GitLab administrator if you think this is a mistake.</p>
<a href="javascript:history.back()" class="js-go-back go-back">Go back</a>
diff --git a/scripts/static-analysis b/scripts/static-analysis
index 52529e64b30..295b6f132c1 100755
--- a/scripts/static-analysis
+++ b/scripts/static-analysis
@@ -12,7 +12,8 @@ tasks = [
%w[bundle exec license_finder],
%w[yarn run eslint],
%w[bundle exec rubocop --require rubocop-rspec],
- %w[scripts/lint-conflicts.sh]
+ %w[scripts/lint-conflicts.sh],
+ %w[bundle exec rake gettext:lint]
]
failed_tasks = tasks.reduce({}) do |failures, task|
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 331903a5543..59a6cfbf4f5 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -8,34 +8,43 @@ describe ApplicationController do
it 'redirects if the user is over their password expiry' do
user.password_expires_at = Time.new(2002)
+
expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
expect(controller).to receive(:redirect_to)
expect(controller).to receive(:new_profile_password_path)
+
controller.send(:check_password_expiration)
end
it 'does not redirect if the user is under their password expiry' do
user.password_expires_at = Time.now + 20010101
+
expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
expect(controller).not_to receive(:redirect_to)
+
controller.send(:check_password_expiration)
end
it 'does not redirect if the user is over their password expiry but they are an ldap user' do
user.password_expires_at = Time.new(2002)
+
allow(user).to receive(:ldap_user?).and_return(true)
allow(controller).to receive(:current_user).and_return(user)
expect(controller).not_to receive(:redirect_to)
+
controller.send(:check_password_expiration)
end
- it 'does not redirect if the user is over their password expiry but sign-in is disabled' do
+ it 'redirects if the user is over their password expiry and sign-in is disabled' do
stub_application_setting(password_authentication_enabled: false)
user.password_expires_at = Time.new(2002)
+
+ expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
- expect(controller).not_to receive(:redirect_to)
+ expect(controller).to receive(:redirect_to)
+ expect(controller).to receive(:new_profile_password_path)
controller.send(:check_password_expiration)
end
diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb
index 2955d01fad0..cdaa88bbf5d 100644
--- a/spec/controllers/passwords_controller_spec.rb
+++ b/spec/controllers/passwords_controller_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
describe PasswordsController do
- describe '#check_password_authentication_available' do
+ describe '#prevent_ldap_reset' do
before do
@request.env["devise.mapping"] = Devise.mappings[:user]
end
context 'when password authentication is disabled' do
- it 'prevents a password reset' do
+ it 'allows password reset' do
stub_application_setting(password_authentication_enabled: false)
post :create
- expect(flash[:alert]).to eq 'Password authentication is unavailable.'
+ expect(response).to have_http_status(302)
end
end
@@ -22,7 +22,7 @@ describe PasswordsController do
it 'prevents a password reset' do
post :create, user: { email: user.email }
- expect(flash[:alert]).to eq 'Password authentication is unavailable.'
+ expect(flash[:alert]).to eq('Cannot reset password for LDAP user.')
end
end
end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index da8f9e8376e..65f4d09cfce 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -879,4 +879,19 @@ describe Projects::IssuesController do
format: :json
end
end
+
+ describe 'GET #discussions' do
+ let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'returns discussion json' do
+ get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
+
+ expect(JSON.parse(response.body).first.keys).to match_array(%w[id reply_id expanded notes individual_note])
+ end
+ end
end
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index f280c55059c..6ffe41b8608 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -46,10 +46,13 @@ describe Projects::NotesController do
end
context 'for a discussion note' do
- let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) }
+ let(:project) { create(:project, :repository) }
+ let!(:note) { create(:discussion_note_on_merge_request, project: project) }
+
+ let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
it 'responds with the expected attributes' do
- get :index, request_params
+ get :index, params
expect(note_json[:id]).to eq(note.id)
expect(note_json[:discussion_html]).not_to be_nil
@@ -104,10 +107,12 @@ describe Projects::NotesController do
end
context 'for a regular note' do
- let!(:note) { create(:note, noteable: issue, project: project) }
+ let!(:note) { create(:note_on_merge_request, project: project) }
+
+ let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
it 'responds with the expected attributes' do
- get :index, request_params
+ get :index, params
expect(note_json[:id]).to eq(note.id)
expect(note_json[:html]).not_to be_nil
@@ -125,7 +130,9 @@ describe Projects::NotesController do
note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' },
namespace_id: project.namespace,
project_id: project,
- merge_request_diff_head_sha: 'sha'
+ merge_request_diff_head_sha: 'sha',
+ target_type: 'merge_request',
+ target_id: merge_request.id
}
end
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index a13b6e3596e..3f7c794b14a 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -18,5 +18,54 @@ FactoryGirl.define do
factory :write_access_key, class: 'DeployKey' do
can_push true
end
+
+ factory :rsa_key_2048 do
+ key do
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O9' \
+ '6x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5' \
+ '/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7' \
+ 'M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaC' \
+ 'rzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy0' \
+ '5qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz= dummy@gitlab.com'
+ end
+
+ factory :rsa_deploy_key_2048, class: 'DeployKey'
+ end
+
+ factory :dsa_key_2048 do
+ key do
+ 'ssh-dss AAAAB3NzaC1kc3MAAAEBAO/3/NPLA/zSFkMOCaTtGo+uos1flfQ5f038Uk+G' \
+ 'Y9AeLGzX+Srhw59GdVXmOQLYBrOt5HdGwqYcmLnE2VurUGmhtfeO5H+3p5pGJbkS0Gxp' \
+ 'YH1HRO9lWsncF3Hh1w4lYsDjkclDiSTdfTuN8F4Kb3DXNnVSCieeonp+B25F/CXagyTQ' \
+ '/pvNmHFeYgGCVdnBtFdi+xfxaZ8NKdPrGggzokbKHElDZQ4Xo5EpdcyLajgM7nB2r2Rz' \
+ 'OrmeaevKi5lV68ehRa9Yyrb7vxvwiwBwOgqR/mnN7Gnaq1jUdmJY+ct04Qwx37f5jvhv' \
+ '5gA4U40SGMoiHM8RFIN7Ksz0jsyX73MAAAAVALRWOfjfzHpK7KLz4iqDvvTUAevJAAAB' \
+ 'AEa9NZ+6y9iQ5erGsdfLTXFrhSefTG0NhghoO/5IFkSGfd8V7kzTvCHaFrcfpEA5kP8t' \
+ 'poeOG0TASB6tgGOxm1Bq4Wncry5RORBPJlAVpDGRcvZ931ddH7IgltEInS6za2uH6F/1' \
+ 'M1QfKePSLr6xJ1ZLYfP0Og5KTp1x6yMQvfwV0a+XdA+EPgaJWLWp/pWwKWa0oLUgjsIH' \
+ 'MYzuOGh5c708uZrmkzqvgtW2NgXhcIroRgynT3IfI2lP2rqqb3uuuE/qH5UCUFO+Dc3H' \
+ 'nAFNeQDT/M25AERdPYBAY5a+iPjIgO+jT7BfmfByT+AZTqZySrCyc7nNZL3YgGLK0l6A' \
+ '1GgAAAEBAN9FpFOdIXE+YEZhKl1vPmbcn+b1y5zOl6N4x1B7Q8pD/pLMziWROIS8uLzb' \
+ 'aZ0sMIWezHIkxuo1iROMeT+jtCubn7ragaN6AX7nMpxYUH9+mYZZs/fyElt6wCviVhTI' \
+ 'zM+u7VdQsnZttOOlQfogHdL+SpeAft0DsfJjlcgQnsLlHQKv6aPqCPYUST2nE7RyW/Ex' \
+ 'PrMxLtOWt0/j8RYHbwwqvyeZqBz3ESBgrS9c5tBdBfauwYUV/E7gPLOU3OZFw9ue7o+z' \
+ 'wzoTZqW6Xouy5wtWvSLQSLT5XwOslmQz8QMBxD0AQyDfEFGsBCWzmbTgKv9uqrBjubsS' \
+ 'Taja+Cf9kMo== dummy@gitlab.com'
+ end
+ end
+
+ factory :ecdsa_key_256 do
+ key do
+ 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYA' \
+ 'AABBBJZmkzTgY0fiCQ+DVReyH/fFwTFz0XoR3RUO0u+199H19KFw7mNPxRSMOVS7tEtO' \
+ 'Nj3Q7FcZXfqthHvgAzDiHsc= dummy@gitlab.com'
+ end
+ end
+
+ factory :ed25519_key_256 do
+ key do
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIETnVTgzqC1gatgSlC4zH6aYt2CAQzgJOhDRvf59ohL6 dummy@gitlab.com'
+ end
+ end
end
end
diff --git a/spec/features/admin/admin_active_tab_spec.rb b/spec/features/admin/admin_active_tab_spec.rb
index 07430ecd6e0..5ff791fc36a 100644
--- a/spec/features/admin/admin_active_tab_spec.rb
+++ b/spec/features/admin/admin_active_tab_spec.rb
@@ -7,15 +7,15 @@ RSpec.describe 'admin active tab' do
shared_examples 'page has active tab' do |title|
it "activates #{title} tab" do
- expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1)
- expect(page.find('.layout-nav li.active')).to have_content(title)
+ expect(page).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1)
+ expect(page.find('.nav-sidebar .sidebar-top-level-items > li.active')).to have_content(title)
end
end
shared_examples 'page has active sub tab' do |title|
it "activates #{title} sub tab" do
- expect(page).to have_selector('.sub-nav li.active', count: 1)
- expect(page.find('.sub-nav li.active')).to have_content(title)
+ expect(page).to have_selector('.sidebar-sub-level-items li.active', count: 1)
+ expect(page.find('.sidebar-sub-level-items li.active')).to have_content(title)
end
end
diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb
index 30fcb334b60..91f08dbad5d 100644
--- a/spec/features/admin/admin_hooks_spec.rb
+++ b/spec/features/admin/admin_hooks_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Admin::Hooks' do
+describe 'Admin::Hooks', :js do
before do
@project = create(:project)
sign_in(create(:admin))
@@ -12,7 +12,7 @@ describe 'Admin::Hooks' do
it 'is ok' do
visit admin_root_path
- page.within '.layout-nav' do
+ page.within '.nav-sidebar' do
click_on 'Hooks'
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index dbb0ae9c86e..563818e8761 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -79,6 +79,22 @@ feature 'Admin updates settings' do
end
end
+ scenario 'Change Keys settings' do
+ select 'Are forbidden', from: 'RSA SSH keys'
+ select 'Are allowed', from: 'DSA SSH keys'
+ select 'Must be at least 384 bits', from: 'ECDSA SSH keys'
+ select 'Are forbidden', from: 'ED25519 SSH keys'
+ click_on 'Save'
+
+ forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE.to_s
+
+ expect(page).to have_content 'Application settings saved successfully'
+ expect(find_field('RSA SSH keys').value).to eq(forbidden)
+ expect(find_field('DSA SSH keys').value).to eq('0')
+ expect(find_field('ECDSA SSH keys').value).to eq('384')
+ expect(find_field('ED25519 SSH keys').value).to eq(forbidden)
+ end
+
def check_all_events
page.check('Active')
page.check('Push')
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index ce458431c55..913258ca40f 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -13,6 +13,8 @@ describe 'Issue Boards', js: true do
project.team << [user, :master]
project.team << [user2, :master]
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
+
sign_in(user)
end
@@ -145,6 +147,8 @@ describe 'Issue Boards', js: true do
click_button 'Add list'
wait_for_requests
+ find('.dropdown-menu-close').click
+
page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
diff --git a/spec/features/dashboard/active_tab_spec.rb b/spec/features/dashboard/active_tab_spec.rb
index 067e4337e6a..08d8cc7922b 100644
--- a/spec/features/dashboard/active_tab_spec.rb
+++ b/spec/features/dashboard/active_tab_spec.rb
@@ -7,9 +7,8 @@ RSpec.describe 'Dashboard Active Tab', js: true do
shared_examples 'page has active tab' do |title|
it "#{title} tab" do
- find('.global-dropdown-toggle').trigger('click')
- expect(page).to have_selector('.global-dropdown-menu li.active', count: 1)
- expect(find('.global-dropdown-menu li.active')).to have_content(title)
+ expect(page).to have_selector('.navbar-sub-nav li.active', count: 1)
+ expect(find('.navbar-sub-nav li.active')).to have_content(title)
end
end
@@ -21,27 +20,19 @@ RSpec.describe 'Dashboard Active Tab', js: true do
it_behaves_like 'page has active tab', 'Projects'
end
- context 'on dashboard issues' do
- before do
- visit issues_dashboard_path
- end
-
- it_behaves_like 'page has active tab', 'Issues'
- end
-
- context 'on dashboard merge requests' do
+ context 'on dashboard groups' do
before do
- visit merge_requests_dashboard_path
+ visit dashboard_groups_path
end
- it_behaves_like 'page has active tab', 'Merge Requests'
+ it_behaves_like 'page has active tab', 'Groups'
end
- context 'on dashboard groups' do
+ context 'on activity projects' do
before do
- visit dashboard_groups_path
+ visit activity_dashboard_path
end
- it_behaves_like 'page has active tab', 'Groups'
+ it_behaves_like 'page has active tab', 'Activity'
end
end
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
index facb67ae787..ebc3d196118 100644
--- a/spec/features/dashboard/issues_filter_spec.rb
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -50,7 +50,7 @@ feature 'Dashboard Issues filtering', :js do
it 'updates atom feed link' do
visit_issues(milestone_title: '', assignee_id: user.id)
- link = find('.nav-controls a[title="Subscribe"]')
+ link = find('.breadcrumbs a[title="Subscribe"]')
params = CGI.parse(URI.parse(link[:href]).query)
auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb
index 5f1f0c10339..e41bd7a8419 100644
--- a/spec/features/dashboard/shortcuts_spec.rb
+++ b/spec/features/dashboard/shortcuts_spec.rb
@@ -50,6 +50,6 @@ feature 'Dashboard shortcuts', :js do
end
def check_page_title(title)
- expect(find('.header-content .title')).to have_content(title)
+ expect(find('.breadcrumbs-sub-title')).to have_content(title)
end
end
diff --git a/spec/features/groups/group_name_toggle_spec.rb b/spec/features/groups/group_name_toggle_spec.rb
deleted file mode 100644
index a7b8b702ab7..00000000000
--- a/spec/features/groups/group_name_toggle_spec.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require 'spec_helper'
-
-feature 'Group name toggle', js: true do
- let(:group) { create(:group) }
- let(:nested_group_1) { create(:group, parent: group) }
- let(:nested_group_2) { create(:group, parent: nested_group_1) }
- let(:nested_group_3) { create(:group, parent: nested_group_2) }
-
- SMALL_SCREEN = 300
-
- before do
- sign_in(create(:user))
- end
-
- it 'is not present if enough horizontal space' do
- visit group_path(nested_group_3)
-
- container_width = page.evaluate_script("$('.title-container')[0].offsetWidth")
- title_width = page.evaluate_script("$('.title')[0].offsetWidth")
-
- expect(container_width).to be > title_width
- expect(page).not_to have_css('.group-name-toggle')
- end
-
- it 'is present if the title is longer than the container', :nested_groups do
- visit group_path(nested_group_3)
- title_width = page.evaluate_script("$('.title')[0].offsetWidth")
-
- page_height = page.current_window.size[1]
- page.current_window.resize_to(SMALL_SCREEN, page_height)
-
- find('.group-name-toggle')
- container_width = page.evaluate_script("$('.title-container')[0].offsetWidth")
-
- expect(title_width).to be > container_width
- end
-
- it 'should show the full group namespace when toggled', :nested_groups do
- page_height = page.current_window.size[1]
- page.current_window.resize_to(SMALL_SCREEN, page_height)
- visit group_path(nested_group_3)
-
- expect(page).not_to have_content(group.name)
- expect(page).to have_css('.group-path.hidable', visible: false)
-
- click_button '...'
-
- expect(page).to have_content(group.name)
- expect(page).to have_css('.group-path.hidable', visible: true)
- end
-end
diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb
index d0316cfb18d..b83bad3befb 100644
--- a/spec/features/groups/group_settings_spec.rb
+++ b/spec/features/groups/group_settings_spec.rb
@@ -65,14 +65,14 @@ feature 'Edit group settings' do
update_path(new_group_path)
visit new_project_full_path
expect(current_path).to eq(new_project_full_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs')).to have_content(project.path)
end
scenario 'the old project path redirects to the new path' do
update_path(new_group_path)
visit old_project_full_path
expect(current_path).to eq(new_project_full_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs')).to have_content(project.path)
end
end
end
diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb
index 9ba9f5686f7..2577d98df6f 100644
--- a/spec/features/groups/merge_requests_spec.rb
+++ b/spec/features/groups/merge_requests_spec.rb
@@ -25,7 +25,7 @@ feature 'Group merge requests page' do
end
it 'ignores archived merge request count badges in navbar' do
- expect( page.find('[title="Merge Requests"] span.badge.count').text).to eq("1")
+ expect( page.find('[aria-label="Merge Requests"] span.badge.count').text).to eq("1")
end
it 'ignores archived merge request count badges in state-filters' do
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 20f9818b08b..4ec2e7e6012 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -158,7 +158,7 @@ feature 'Group' do
expect(page).to have_content 'successfully updated'
expect(find('#group_name').value).to eq(new_name)
- page.within ".navbar-gitlab" do
+ page.within ".breadcrumbs" do
expect(page).to have_content new_name
end
end
diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb
index 134e618feac..a29acb30163 100644
--- a/spec/features/issues/award_emoji_spec.rb
+++ b/spec/features/issues/award_emoji_spec.rb
@@ -70,13 +70,13 @@ describe 'Awards Emoji' do
it 'toggles the smiley emoji on a note', js: true do
toggle_smiley_emoji(true)
- within('.note-awards') do
+ within('.note-body') do
expect(find(emoji_counter)).to have_text("1")
end
toggle_smiley_emoji(false)
- within('.note-awards') do
+ within('.note-body') do
expect(page).not_to have_selector(emoji_counter)
end
end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index a64c1cf220b..3ea6e1c8863 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -1,26 +1,24 @@
require 'spec_helper'
describe 'Filter issues', js: true do
- include Devise::Test::IntegrationHelpers
include FilteredSearchHelpers
- let!(:group) { create(:group) }
- let!(:project) { create(:project, group: group) }
- let!(:user) { create(:user, username: 'joe', name: 'Joe') }
- let!(:user2) { create(:user, username: 'jane') }
- let!(:label) { create(:label, project: project) }
- let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
+ let(:project) { create(:project) }
+
+ # NOTE: The short name here is actually important
+ #
+ # When the name is longer, the filtered search input can end up scrolling
+ # horizontally, and PhantomJS can't handle it.
+ let(:user) { create(:user, name: 'Ann') }
let!(:bug_label) { create(:label, project: project, title: 'bug') }
let!(:caps_sensitive_label) { create(:label, project: project, title: 'CaPs') }
- let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) }
let!(:multiple_words_label) { create(:label, project: project, title: "Two words") }
-
- let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) }
+ let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) }
def expect_no_issues_list
page.within '.issues-list' do
- expect(page).not_to have_selector('.issue')
+ expect(page).to have_no_selector('.issue')
end
end
@@ -33,63 +31,62 @@ describe 'Filter issues', js: true do
end
end
- def select_search_at_index(pos)
- evaluate_script("el = document.querySelector('.filtered-search'); el.focus(); el.setSelectionRange(#{pos}, #{pos});")
- end
-
before do
- project.team << [user, :master]
- project.team << [user2, :master]
- group.add_developer(user)
- group.add_developer(user2)
+ project.add_master(user)
- sign_in(user)
+ user2 = create(:user)
- create(:issue, project: project)
- create(:issue, project: project, title: "Bug report 1")
- create(:issue, project: project, title: "Bug report 2")
- create(:issue, project: project, title: "issue with 'single quotes'")
- create(:issue, project: project, title: "issue with \"double quotes\"")
- create(:issue, project: project, title: "issue with !@\#{$%^&*()-+")
- create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user])
- create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user])
+ create(:issue, project: project, author: user2, title: "Bug report 1")
+ create(:issue, project: project, author: user2, title: "Bug report 2")
- issue = create(:issue,
+ create(:issue, project: project, author: user, title: "issue by assignee", milestone: milestone, assignees: [user])
+ create(:issue, project: project, author: user, title: "issue by assignee with searchTerm", milestone: milestone, assignees: [user])
+
+ create(:labeled_issue,
title: "Bug 2",
project: project,
milestone: milestone,
author: user,
- assignees: [user])
- issue.labels << bug_label
+ assignees: [user],
+ labels: [bug_label])
- issue_with_caps_label = create(:issue,
+ create(:labeled_issue,
title: "issue by assignee with searchTerm and label",
project: project,
milestone: milestone,
author: user,
- assignees: [user])
- issue_with_caps_label.labels << caps_sensitive_label
+ assignees: [user],
+ labels: [caps_sensitive_label])
- issue_with_everything = create(:issue,
+ create(:labeled_issue,
title: "Bug report foo was possible",
project: project,
milestone: milestone,
author: user,
- assignees: [user])
- issue_with_everything.labels << bug_label
- issue_with_everything.labels << caps_sensitive_label
+ assignees: [user],
+ labels: [bug_label, caps_sensitive_label])
+
+ create(:labeled_issue, title: "Issue with multiple words label", project: project, labels: [multiple_words_label])
- multiple_words_label_issue = create(:issue, title: "Issue with multiple words label", project: project)
- multiple_words_label_issue.labels << multiple_words_label
+ sign_in(user)
+ visit project_issues_path(project)
+ end
- future_milestone = create(:milestone, title: "future", project: project, due_date: Time.now + 1.month)
+ it 'filters by all available tokens' do
+ search_term = 'issue'
- create(:issue,
- title: "Issue with future milestone",
- milestone: future_milestone,
- project: project)
+ input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
- visit project_issues_path(project)
+ wait_for_requests
+
+ expect_tokens([
+ assignee_token(user.name),
+ author_token(user.name),
+ label_token(caps_sensitive_label.title),
+ milestone_token(milestone.title)
+ ])
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search_term)
end
describe 'filter issues by author' do
@@ -104,59 +101,6 @@ describe 'Filter issues', js: true do
expect_filtered_search_input_empty
end
end
-
- context 'author with other filters' do
- let(:search_term) { 'issue' }
-
- it 'filters issues by searched author and text' do
- input_filtered_search("author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([author_token(user.name)])
- expect_issues_list_count(3)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched author, assignee and text' do
- input_filtered_search("author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([author_token(user.name), assignee_token(user.name)])
- expect_issues_list_count(3)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched author, assignee, label, and text' do
- input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- author_token(user.name),
- assignee_token(user.name),
- label_token(caps_sensitive_label.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched author, assignee, label, milestone and text' do
- input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- author_token(user.name),
- assignee_token(user.name),
- label_token(caps_sensitive_label.title),
- milestone_token(milestone.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
- end
end
describe 'filter issues by assignee' do
@@ -175,66 +119,13 @@ describe 'Filter issues', js: true do
input_filtered_search('assignee:none')
expect_tokens([assignee_token('none')])
- expect_issues_list_count(8, 1)
+ expect_issues_list_count(3)
expect_filtered_search_input_empty
end
end
-
- context 'assignee with other filters' do
- let(:search_term) { 'searchTerm' }
-
- it 'filters issues by searched assignee and text' do
- input_filtered_search("assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([assignee_token(user.name)])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched assignee, author and text' do
- input_filtered_search("assignee:@#{user.username} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([assignee_token(user.name), author_token(user.name)])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched assignee, author, label, text' do
- input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- assignee_token(user.name),
- author_token(user.name),
- label_token(caps_sensitive_label.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched assignee, author, label, milestone and text' do
- input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
-
- expect_tokens([
- assignee_token(user.name),
- author_token(user.name),
- label_token(caps_sensitive_label.title),
- milestone_token(milestone.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
- end
end
describe 'filter issues by label' do
- let(:search_term) { 'bug' }
-
context 'only label' do
it 'filters issues by searched label' do
input_filtered_search("label:~#{bug_label.title}")
@@ -248,7 +139,7 @@ describe 'Filter issues', js: true do
input_filtered_search('label:none')
expect_tokens([label_token('none', false)])
- expect_issues_list_count(9, 1)
+ expect_issues_list_count(8)
expect_filtered_search_input_empty
end
@@ -275,13 +166,13 @@ describe 'Filter issues', js: true do
expect_filtered_search_input_empty
end
- it 'does not show issues' do
+ it 'does not show issues for unused labels' do
new_label = create(:label, project: project, title: 'new_label')
input_filtered_search("label:~#{new_label.title}")
expect_tokens([label_token(new_label.title)])
- expect_no_issues_list()
+ expect_no_issues_list
expect_filtered_search_input_empty
end
end
@@ -344,95 +235,10 @@ describe 'Filter issues', js: true do
end
end
- context 'label with other filters' do
- it 'filters issues by searched label and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} #{search_term}")
-
- expect_tokens([label_token(caps_sensitive_label.title)])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, author and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([label_token(caps_sensitive_label.title), author_token(user.name)])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, author, assignee and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, author, assignee, milestone and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
-
- expect_tokens([
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name),
- milestone_token(milestone.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
- end
-
context 'multiple labels with other filters' do
- it 'filters issues by searched label, label2, and text' do
- input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} #{search_term}")
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, label2, author and text' do
- input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title),
- author_token(user.name)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, label2, author, assignee and text' do
- input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
it 'filters issues by searched label, label2, author, assignee, milestone and text' do
+ search_term = 'bug'
+
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
wait_for_requests
@@ -450,15 +256,10 @@ describe 'Filter issues', js: true do
end
context 'issue label clicked' do
- before do
+ it 'filters and displays in search bar' do
find('.issues-list .issue .issue-main-info .issuable-info a .label', text: multiple_words_label.title).click
- end
- it 'filters' do
expect_issues_list_count(1)
- end
-
- it 'displays in search bar' do
expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_filtered_search_input_empty
end
@@ -479,11 +280,15 @@ describe 'Filter issues', js: true do
input_filtered_search("milestone:none")
expect_tokens([milestone_token('none', false)])
- expect_issues_list_count(7, 1)
+ expect_issues_list_count(3)
expect_filtered_search_input_empty
end
it 'filters issues by upcoming milestones' do
+ create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
+ create(:issue, project: project, milestone: future_milestone, author: user)
+ end
+
input_filtered_search("milestone:upcoming")
expect_tokens([milestone_token('upcoming', false)])
@@ -501,7 +306,7 @@ describe 'Filter issues', js: true do
it 'filters issues by milestone containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
- create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone)
+ create(:issue, project: project, milestone: special_milestone)
input_filtered_search("milestone:%#{special_milestone.title}")
@@ -510,70 +315,16 @@ describe 'Filter issues', js: true do
expect_filtered_search_input_empty
end
- it 'does not show issues' do
- new_milestone = create(:milestone, title: "new", project: project)
+ it 'does not show issues for unused milestones' do
+ new_milestone = create(:milestone, title: 'new', project: project)
input_filtered_search("milestone:%#{new_milestone.title}")
expect_tokens([milestone_token(new_milestone.title)])
- expect_no_issues_list()
+ expect_no_issues_list
expect_filtered_search_input_empty
end
end
-
- context 'milestone with other filters' do
- let(:search_term) { 'bug' }
-
- it 'filters issues by searched milestone and text' do
- input_filtered_search("milestone:%#{milestone.title} #{search_term}")
-
- expect_tokens([milestone_token(milestone.title)])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched milestone, author and text' do
- input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- milestone_token(milestone.title),
- author_token(user.name)
- ])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched milestone, author, assignee and text' do
- input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- milestone_token(milestone.title),
- author_token(user.name),
- assignee_token(user.name)
- ])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched milestone, author, assignee, label and text' do
- input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- milestone_token(milestone.title),
- author_token(user.name),
- assignee_token(user.name),
- label_token(bug_label.title)
- ])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
- end
end
describe 'filter issues by text' do
@@ -582,7 +333,7 @@ describe 'Filter issues', js: true do
search = 'Bug'
input_filtered_search(search)
- expect_issues_list_count(4, 1)
+ expect_issues_list_count(4)
expect_filtered_search_input(search)
end
@@ -603,112 +354,50 @@ describe 'Filter issues', js: true do
end
it 'filters issues by searched text containing single quotes' do
- search = '\'single quotes\''
+ issue = create(:issue, project: project, author: user, title: "issue with 'single quotes'")
+
+ search = "'single quotes'"
input_filtered_search(search)
expect_issues_list_count(1)
expect_filtered_search_input(search)
+ expect(page).to have_content(issue.title)
end
it 'filters issues by searched text containing double quotes' do
+ issue = create(:issue, project: project, author: user, title: "issue with \"double quotes\"")
+
search = '"double quotes"'
input_filtered_search(search)
expect_issues_list_count(1)
expect_filtered_search_input(search)
+ expect(page).to have_content(issue.title)
end
it 'filters issues by searched text containing special characters' do
+ issue = create(:issue, project: project, author: user, title: "issue with !@\#{$%^&*()-+")
+
search = '!@#{$%^&*()-+'
input_filtered_search(search)
expect_issues_list_count(1)
expect_filtered_search_input(search)
+ expect(page).to have_content(issue.title)
end
it 'does not show any issues' do
search = 'testing'
input_filtered_search(search)
- expect_no_issues_list()
+ expect_no_issues_list
expect_filtered_search_input(search)
end
end
context 'searched text with other filters' do
- it 'filters issues by searched text and author' do
- # After searching, all search terms are placed at the end
- input_filtered_search("bug author:@#{user.username}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author and more text' do
- input_filtered_search("bug author:@#{user.username} report")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report')
- end
-
- it 'filters issues by searched text, author and assignee' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author, more text and assignee' do
- input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report')
- end
-
- it 'filters issues by searched text, author, more text, assignee and even more text' do
- input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} foo")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
- end
-
- it 'filters issues by searched text, author, assignee and label' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author, text, assignee, text, label and text' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} foo")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
- end
-
- it 'filters issues by searched text, author, assignee, label and milestone' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} milestone:%#{milestone.title} foo")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
- end
-
- it 'filters issues by searched text, author, assignee, multiple labels and milestone' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug')
- end
-
it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo")
+ input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo")
expect_issues_list_count(1)
expect_filtered_search_input('bug report foo')
@@ -746,7 +435,9 @@ describe 'Filter issues', js: true do
end
end
- describe 'retains filter when switching issue states' do
+ describe 'switching issue states' do
+ let!(:closed_issue) { create(:issue, :closed, project: project, title: 'closed bug') }
+
before do
input_filtered_search('bug')
@@ -754,25 +445,21 @@ describe 'Filter issues', js: true do
expect_issues_list_count(4, 1)
end
- it 'open state' do
+ it 'maintains filter' do
+ # Closed
find('.issues-state-filters [data-state="closed"]').click
wait_for_requests
+ expect(page).to have_selector('.issues-list .issue', count: 1)
+ expect(page).to have_link(closed_issue.title)
+
+ # Opened
find('.issues-state-filters [data-state="opened"]').click
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 4)
- end
- it 'closed state' do
- find('.issues-state-filters [data-state="closed"]').click
- wait_for_requests
-
- expect(page).to have_selector('.issues-list .issue', count: 1)
- expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title)
- end
-
- it 'all state' do
+ # All
find('.issues-state-filters [data-state="all"]').click
wait_for_requests
@@ -781,34 +468,39 @@ describe 'Filter issues', js: true do
end
describe 'RSS feeds' do
- it 'updates atom feed link for project issues' do
- visit project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id)
- link = find_link('Subscribe')
- params = CGI.parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
-
- expect(params).to include('rss_token' => [user.rss_token])
- expect(params).to include('milestone_title' => [milestone.title])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
- expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
+
+ before do
+ group.add_developer(user)
+ end
+
+ shared_examples 'updates atom feed link' do |type|
+ it "for #{type}" do
+ visit path
+
+ link = find_link('Subscribe')
+ params = CGI.parse(URI.parse(link[:href]).query)
+ auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
+ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+
+ expected = {
+ 'rss_token' => [user.rss_token],
+ 'milestone_title' => [milestone.title],
+ 'assignee_id' => [user.id.to_s]
+ }
+
+ expect(params).to include(expected)
+ expect(auto_discovery_params).to include(expected)
+ end
+ end
+
+ it_behaves_like 'updates atom feed link', :project do
+ let(:path) { project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id) }
end
- it 'updates atom feed link for group issues' do
- visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id)
- link = find('.nav-controls a', text: 'Subscribe')
- params = CGI.parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
-
- expect(params).to include('rss_token' => [user.rss_token])
- expect(params).to include('milestone_title' => [milestone.title])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
- expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ it_behaves_like 'updates atom feed link', :group do
+ let(:path) { issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) }
end
end
@@ -821,7 +513,7 @@ describe 'Filter issues', js: true do
input_filtered_search("milestone:", submit: false)
within('#js-dropdown-milestone') do
- expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 2)
+ expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
end
@@ -829,7 +521,7 @@ describe 'Filter issues', js: true do
input_filtered_search("label:", submit: false)
within('#js-dropdown-label') do
- expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5)
+ expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3)
end
end
end
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index 14a555fde10..4ae54fd6f4e 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -28,6 +28,8 @@ describe 'Visual tokens', js: true do
sign_in(user)
create(:issue, project: project)
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
+
visit project_issues_path(project)
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index b84635c5134..c6cf6265645 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -28,8 +28,8 @@ feature 'GFM autocomplete', js: true do
it 'opens autocomplete menu when field starts with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.atwho-container')
@@ -37,8 +37,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('testing')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('testing')
+ find('#note-body').native.send_keys('@')
end
expect(page).not_to have_selector('.atwho-view')
@@ -46,8 +46,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt select the first item for non-assignee dropdowns' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys(':')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys(':')
end
expect(page).to have_selector('.atwho-container')
@@ -58,7 +58,7 @@ feature 'GFM autocomplete', js: true do
end
it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do
- note = find('#note_note')
+ note = find('#note-body')
# Number.
page.within '.timeline-content-form' do
@@ -86,8 +86,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for assignee dropdowns' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.atwho-container')
@@ -99,8 +99,8 @@ feature 'GFM autocomplete', js: true do
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys("@#{user.name[0...8]}")
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys("@#{user.name[0...8]}")
end
expect(page).to have_selector('.atwho-container')
@@ -112,8 +112,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for non-assignee dropdowns if a query is entered' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys(':1')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys(':1')
end
expect(page).to have_selector('.atwho-container')
@@ -125,7 +125,7 @@ feature 'GFM autocomplete', js: true do
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("~#{label.title[0]}")
@@ -138,7 +138,7 @@ feature 'GFM autocomplete', js: true do
end
it "shows dropdown after a new line" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('test')
note.native.send_keys(:enter)
@@ -150,7 +150,7 @@ feature 'GFM autocomplete', js: true do
end
it "does not show dropdown when preceded with a special character" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("@")
@@ -168,7 +168,7 @@ feature 'GFM autocomplete', js: true do
end
it "does not throw an error if no labels exist" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys('~')
@@ -179,7 +179,7 @@ feature 'GFM autocomplete', js: true do
end
it 'doesn\'t wrap for assignee values' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("@#{user.username[0]}")
@@ -192,7 +192,7 @@ feature 'GFM autocomplete', js: true do
end
it 'doesn\'t wrap for emoji values' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys(":cartwheel")
@@ -206,7 +206,7 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete after non-word character' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys("@#{user.username[0..2]}!")
+ find('#note-body').native.send_keys("@#{user.username[0..2]}!")
end
expect(page).not_to have_selector('.atwho-view')
@@ -214,14 +214,14 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete if there is no space before' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys("hello:#{user.username[0..2]}")
+ find('#note-body').native.send_keys("hello:#{user.username[0..2]}")
end
expect(page).not_to have_selector('.atwho-view')
end
it 'triggers autocomplete after selecting a quick action' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys('/as')
diff --git a/spec/features/issues/markdown_toolbar_spec.rb b/spec/features/issues/markdown_toolbar_spec.rb
index 8c23fcd483b..634ea111dc1 100644
--- a/spec/features/issues/markdown_toolbar_spec.rb
+++ b/spec/features/issues/markdown_toolbar_spec.rb
@@ -12,26 +12,26 @@ feature 'Issue markdown toolbar', js: true do
end
it "doesn't include first new line when adding bold" do
- find('#note_note').native.send_keys('test')
- find('#note_note').native.send_key(:enter)
- find('#note_note').native.send_keys('bold')
+ find('#note-body').native.send_keys('test')
+ find('#note-body').native.send_key(:enter)
+ find('#note-body').native.send_keys('bold')
- page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 9)')
+ page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 9)')
first('.toolbar-btn').click
- expect(find('#note_note')[:value]).to eq("test\n**bold**\n")
+ expect(find('#note-body')[:value]).to eq("test\n**bold**\n")
end
it "doesn't include first new line when adding underline" do
- find('#note_note').native.send_keys('test')
- find('#note_note').native.send_key(:enter)
- find('#note_note').native.send_keys('underline')
+ find('#note-body').native.send_keys('test')
+ find('#note-body').native.send_key(:enter)
+ find('#note-body').native.send_keys('underline')
- page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 50)')
+ page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 50)')
find('.toolbar-btn:nth-child(2)').click
- expect(find('#note_note')[:value]).to eq("test\n*underline*\n")
+ expect(find('#note-body')[:value]).to eq("test\n*underline*\n")
end
end
diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb
index 62dbc3efb01..793572851da 100644
--- a/spec/features/issues/note_polling_spec.rb
+++ b/spec/features/issues/note_polling_spec.rb
@@ -13,7 +13,7 @@ feature 'Issue notes polling', :js do
it 'displays the new comment' do
note = create(:note, noteable: issue, project: project, note: 'Looks good!')
- page.execute_script('notes.refresh();')
+ wait_for_requests
expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!')
end
@@ -31,16 +31,6 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
end
- it 'has .original-note-content to compare against' do
- expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
-
- update_note(existing_note, updated_text)
-
- expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
- end
-
it 'displays the updated content' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
@@ -49,24 +39,14 @@ feature 'Issue notes polling', :js do
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
end
- it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do
+ it 'when editing but have not changed anything, and an update comes in, show warning and does not update the note' do
click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text)
update_note(existing_note, updated_text)
- expect(page).to have_field("note[note]", with: updated_text)
- end
-
- it 'when editing but you changed some things, and an update comes in, show a warning' do
- click_edit_action(existing_note)
-
- expect(page).to have_field("note[note]", with: note_text)
-
- find("#note_#{existing_note.id} .js-note-text").set('something random')
- update_note(existing_note, updated_text)
-
+ expect(page).not_to have_field("note[note]", with: updated_text)
expect(page).to have_selector(".alert")
end
@@ -75,8 +55,6 @@ feature 'Issue notes polling', :js do
expect(page).to have_field("note[note]", with: note_text)
- find("#note_#{existing_note.id} .js-note-text").set('something random')
-
update_note(existing_note, updated_text)
find("#note_#{existing_note.id} .note-edit-cancel").click
@@ -97,14 +75,12 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
end
- it 'has .original-note-content to compare against' do
+ it 'displays the updated content' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
update_note(existing_note, updated_text)
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
end
end
@@ -118,16 +94,15 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
end
- it 'has .original-note-content to compare against' do
+ it 'shows the system note' do
expect(page).to have_selector("#note_#{system_note.id}", text: note_text)
- expect(page).to have_selector("#note_#{system_note.id} .original-note-content", count: 1, visible: false)
end
end
end
def update_note(note, new_text)
note.update(note: new_text)
- page.execute_script('notes.refresh();')
+ wait_for_requests
end
def click_edit_action(note)
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
index 4b63cc844f3..9261acda9dc 100644
--- a/spec/features/issues/user_uses_slash_commands_spec.rb
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -155,5 +155,114 @@ feature 'Issues > User uses quick actions', js: true do
end
end
end
+
+ describe 'move the issue to another project' do
+ let(:issue) { create(:issue, project: project) }
+
+ context 'when the project is valid', js: true do
+ let(:target_project) { create(:project, :public) }
+
+ before do
+ target_project.team << [user, :master]
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'moves the issue' do
+ write_note("/move #{target_project.full_path}")
+
+ expect(page).to have_content 'Commands applied'
+ expect(issue.reload).to be_closed
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'Issues 1'
+ end
+ end
+
+ context 'when the project is valid but the user not authorized', js: true do
+ let(:project_unauthorized) {create(:project, :public)}
+
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'does not move the issue' do
+ write_note("/move #{project_unauthorized.full_path}")
+
+ expect(page).not_to have_content 'Commands applied'
+ expect(issue.reload).to be_open
+ end
+ end
+
+ context 'when the project is invalid', js: true do
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'does not move the issue' do
+ write_note("/move not/valid")
+
+ expect(page).not_to have_content 'Commands applied'
+ expect(issue.reload).to be_open
+ end
+ end
+
+ context 'when the user issues multiple commands', js: true do
+ let(:target_project) { create(:project, :public) }
+ let(:milestone) { create(:milestone, title: '1.0', project: project) }
+ let(:target_milestone) { create(:milestone, title: '1.0', project: target_project) }
+ let(:bug) { create(:label, project: project, title: 'bug') }
+ let(:wontfix) { create(:label, project: project, title: 'wontfix') }
+ let(:bug_target) { create(:label, project: target_project, title: 'bug') }
+ let(:wontfix_target) { create(:label, project: target_project, title: 'wontfix') }
+
+ before do
+ target_project.team << [user, :master]
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'applies the commands to both issues and moves the issue' do
+ write_note("/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"\n/move #{target_project.full_path}")
+
+ expect(page).to have_content 'Commands applied'
+ expect(issue.reload).to be_closed
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content 'Closed'
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+ end
+
+ it 'moves the issue and applies the commands to both issues' do
+ write_note("/move #{target_project.full_path}\n/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"")
+
+ expect(page).to have_content 'Commands applied'
+ expect(issue.reload).to be_closed
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content 'Closed'
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+ end
+ end
+ end
end
end
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index 3ffc80622f5..11db1105d91 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -271,17 +271,21 @@ describe 'Issues' do
it 'filters by none' do
visit project_issues_path(project, due_date: Issue::NoDueDate.name)
- expect(page).not_to have_content('foo')
- expect(page).not_to have_content('bar')
- expect(page).to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).not_to have_content('foo')
+ expect(page).not_to have_content('bar')
+ expect(page).to have_content('baz')
+ end
end
it 'filters by any' do
visit project_issues_path(project, due_date: Issue::AnyDueDate.name)
- expect(page).to have_content('foo')
- expect(page).to have_content('bar')
- expect(page).to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).to have_content('foo')
+ expect(page).to have_content('bar')
+ expect(page).to have_content('baz')
+ end
end
it 'filters by due this week' do
@@ -291,9 +295,11 @@ describe 'Issues' do
visit project_issues_path(project, due_date: Issue::DueThisWeek.name)
- expect(page).to have_content('foo')
- expect(page).to have_content('bar')
- expect(page).not_to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).to have_content('foo')
+ expect(page).to have_content('bar')
+ expect(page).not_to have_content('baz')
+ end
end
it 'filters by due this month' do
@@ -303,9 +309,11 @@ describe 'Issues' do
visit project_issues_path(project, due_date: Issue::DueThisMonth.name)
- expect(page).to have_content('foo')
- expect(page).to have_content('bar')
- expect(page).not_to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).to have_content('foo')
+ expect(page).to have_content('bar')
+ expect(page).not_to have_content('baz')
+ end
end
it 'filters by overdue' do
@@ -315,9 +323,11 @@ describe 'Issues' do
visit project_issues_path(project, due_date: Issue::Overdue.name)
- expect(page).not_to have_content('foo')
- expect(page).not_to have_content('bar')
- expect(page).to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).not_to have_content('foo')
+ expect(page).not_to have_content('bar')
+ expect(page).to have_content('baz')
+ end
end
end
@@ -567,7 +577,9 @@ describe 'Issues' do
it 'redirects to signin then back to new issue after signin' do
visit project_issues_path(project)
- click_link 'New issue'
+ page.within '.breadcrumbs' do
+ click_link 'New issue'
+ end
expect(current_path).to eq new_user_session_path
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
index d7f3d91e625..96e8027a54d 100644
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ b/spec/features/merge_requests/create_new_mr_spec.rb
@@ -13,7 +13,9 @@ feature 'Create New Merge Request', js: true do
it 'selects the source branch sha when a tag with the same name exists' do
visit project_merge_requests_path(project)
- click_link 'New merge request'
+ page.within '.content' do
+ click_link 'New merge request'
+ end
expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch')
@@ -26,7 +28,9 @@ feature 'Create New Merge Request', js: true do
it 'selects the target branch sha when a tag with the same name exists' do
visit project_merge_requests_path(project)
- click_link 'New merge request'
+ page.within '.content' do
+ click_link 'New merge request'
+ end
expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch')
@@ -40,7 +44,9 @@ feature 'Create New Merge Request', js: true do
it 'generates a diff for an orphaned branch' do
visit project_merge_requests_path(project)
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '.content' do
+ click_link 'New merge request'
+ end
expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch')
diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb
index c4f02311f13..e77f1f92731 100644
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb
@@ -21,6 +21,8 @@ feature 'Diff note avatars', js: true do
before do
project.team << [user, :master]
sign_in user
+
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
end
context 'discussion tab' do
diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb
index a8f5dc275e4..e9068f722d5 100644
--- a/spec/features/merge_requests/diffs_spec.rb
+++ b/spec/features/merge_requests/diffs_spec.rb
@@ -88,7 +88,7 @@ feature 'Diffs URL', js: true do
visit diffs_project_merge_request_path(project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
- find("[id=\"#{changelog_id}\"] .js-edit-blob").click
+ find("[id=\"#{changelog_id}\"] .js-edit-blob").trigger('click')
expect(page).to have_selector('.js-fork-suggestion-button', count: 1)
expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1)
diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
index b1215f9ba63..dcc70338d7f 100644
--- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb
+++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
@@ -70,7 +70,7 @@ feature 'Mini Pipeline Graph', :js do
it 'should show tooltip when hovered' do
toggle.hover
- expect(toggle.find(:xpath, '..')).to have_selector('.tooltip')
+ expect(page).to have_selector('.tooltip')
end
end
@@ -117,7 +117,7 @@ feature 'Mini Pipeline Graph', :js do
it 'should show tooltip when hovered' do
build_item.hover
- expect(build_item.find(:xpath, '..')).to have_selector('.tooltip')
+ expect(page).to have_selector('.tooltip')
end
end
end
diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
index f89dd38e5cd..877f305120e 100644
--- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
@@ -6,6 +6,8 @@ feature 'Merge requests > User posts diff notes', :js do
let(:project) { merge_request.source_project }
before do
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
+
project.add_developer(user)
sign_in(user)
end
diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb
index a22d548eef3..96f6df587e1 100644
--- a/spec/features/participants_autocomplete_spec.rb
+++ b/spec/features/participants_autocomplete_spec.rb
@@ -11,10 +11,14 @@ feature 'Member autocomplete', :js do
sign_in(user)
end
- shared_examples "open suggestions when typing @" do
+ shared_examples "open suggestions when typing @" do |resource_name|
before do
page.within('.new-note') do
- find('#note_note').send_keys('@')
+ if resource_name == 'issue'
+ find('#note-body').send_keys('@')
+ else
+ find('#note_note').send_keys('@')
+ end
end
end
@@ -32,7 +36,7 @@ feature 'Member autocomplete', :js do
visit project_issue_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'issue'
end
context 'adding a new note on a Merge Request' do
@@ -45,7 +49,7 @@ feature 'Member autocomplete', :js do
visit project_merge_request_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'merge_request'
end
context 'adding a new note on a Commit' do
@@ -60,6 +64,6 @@ feature 'Member autocomplete', :js do
visit project_commit_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'commit'
end
end
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
index dcd0449dbcb..171e061e60e 100644
--- a/spec/features/profiles/account_spec.rb
+++ b/spec/features/profiles/account_spec.rb
@@ -43,14 +43,14 @@ feature 'Profile > Account' do
update_username(new_username)
visit new_project_path
expect(current_path).to eq(new_project_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs-sub-title')).to have_content(project.path)
end
scenario 'the old project path redirects to the new path' do
update_username(new_username)
visit old_project_path
expect(current_path).to eq(new_project_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs-sub-title')).to have_content(project.path)
end
end
end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index 6541ea6bf57..aa71c4dbba4 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -28,6 +28,23 @@ feature 'Profile > SSH Keys' do
expect(page).to have_content("Title: #{attrs[:title]}")
expect(page).to have_content(attrs[:key])
end
+
+ context 'when only DSA and ECDSA keys are allowed' do
+ before do
+ forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE
+ stub_application_setting(rsa_key_restriction: forbidden, ed25519_key_restriction: forbidden)
+ end
+
+ scenario 'shows a validation error' do
+ attrs = attributes_for(:key)
+
+ fill_in('Key', with: attrs[:key])
+ fill_in('Title', with: attrs[:title])
+ click_button('Add key')
+
+ expect(page).to have_content('Key type is forbidden. Must be DSA or ECDSA')
+ end
+ end
end
scenario 'User sees their keys' do
diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb
index 2c757f99a27..225d4c16841 100644
--- a/spec/features/profiles/password_spec.rb
+++ b/spec/features/profiles/password_spec.rb
@@ -53,12 +53,12 @@ describe 'Profile > Password' do
context 'Regular user' do
let(:user) { create(:user) }
- it 'renders 404 when sign-in is disabled' do
+ it 'renders 200 when sign-in is disabled' do
stub_application_setting(password_authentication_enabled: false)
visit edit_profile_password_path
- expect(page).to have_http_status(404)
+ expect(page).to have_http_status(200)
end
end
diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb
index 2385e1d9333..98c7ef57a51 100644
--- a/spec/features/projects/guest_navigation_menu_spec.rb
+++ b/spec/features/projects/guest_navigation_menu_spec.rb
@@ -13,8 +13,8 @@ describe 'Guest navigation menu' do
it 'shows allowed tabs only' do
visit project_path(project)
- within('.layout-nav') do
- expect(page).to have_content 'Project'
+ within('.nav-sidebar') do
+ expect(page).to have_content 'Overview'
expect(page).to have_content 'Issues'
expect(page).to have_content 'Wiki'
diff --git a/spec/features/projects/issuable_counts_caching_spec.rb b/spec/features/projects/issuable_counts_caching_spec.rb
deleted file mode 100644
index 1804d9dc244..00000000000
--- a/spec/features/projects/issuable_counts_caching_spec.rb
+++ /dev/null
@@ -1,132 +0,0 @@
-require 'spec_helper'
-
-describe 'Issuable counts caching', :use_clean_rails_memory_store_caching do
- let!(:member) { create(:user) }
- let!(:member_2) { create(:user) }
- let!(:non_member) { create(:user) }
- let!(:project) { create(:project, :public) }
- let!(:open_issue) { create(:issue, project: project) }
- let!(:confidential_issue) { create(:issue, :confidential, project: project, author: non_member) }
- let!(:closed_issue) { create(:issue, :closed, project: project) }
-
- before do
- project.add_developer(member)
- project.add_developer(member_2)
- end
-
- it 'caches issuable counts correctly for non-members' do
- # We can't use expect_any_instance_of because that uses a single instance.
- counts = 0
-
- allow_any_instance_of(IssuesFinder).to receive(:count_by_state).and_wrap_original do |m, *args|
- counts += 1
-
- m.call(*args)
- end
-
- aggregate_failures 'only counts once on first load with no params, and caches for later loads' do
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
- end
-
- aggregate_failures 'uses counts from cache on load from non-member' do
- sign_in(non_member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(non_member)
- end
-
- aggregate_failures 'does not use the same cache for a member' do
- sign_in(member)
-
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- sign_out(member)
- end
-
- aggregate_failures 'uses the same cache for all members' do
- sign_in(member_2)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
-
- aggregate_failures 'shares caches when params are passed' do
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .to change { counts }.by(1)
-
- sign_in(member)
-
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .to change { counts }.by(1)
-
- sign_in(non_member)
-
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .not_to change { counts }
-
- sign_in(member_2)
-
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
-
- aggregate_failures 'resets caches on issue close' do
- Issues::CloseService.new(project, member).execute(open_issue)
-
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- sign_in(member)
-
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- sign_in(non_member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(member_2)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
-
- aggregate_failures 'does not reset caches on issue update' do
- Issues::UpdateService.new(project, member, title: 'new title').execute(open_issue)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(non_member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(member_2)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
- end
-end
diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb
index 24c9f708456..0fbe1ddb2a5 100644
--- a/spec/features/projects/members/user_requests_access_spec.rb
+++ b/spec/features/projects/members/user_requests_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Members > User requests access' do
+feature 'Projects > Members > User requests access', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :access_requestable, :repository) }
let(:master) { project.owner }
@@ -46,11 +46,10 @@ feature 'Projects > Members > User requests access' do
expect(project.requesters.exists?(user_id: user)).to be_truthy
- page.within('.layout-nav .nav-links') do
+ page.within('.nav-sidebar') do
click_link('Members')
end
- visit project_project_members_path(project)
page.within('.content') do
expect(page).not_to have_content(user.name)
end
diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb
index 80d91e5915f..5d77cd1ccd5 100644
--- a/spec/features/projects/project_settings_spec.rb
+++ b/spec/features/projects/project_settings_spec.rb
@@ -46,7 +46,7 @@ describe 'Edit Project Settings' do
context 'when changing project name' do
it 'renames the repository' do
rename_project(project, name: 'bar')
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
context 'with emojis' do
@@ -74,7 +74,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(project.namespace, 'bar')
visit new_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
specify 'the project is accessible via a redirect from the old path' do
@@ -83,7 +83,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(project.namespace, 'bar')
visit old_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
context 'and a new project is added with the same path' do
@@ -93,7 +93,7 @@ describe 'Edit Project Settings' do
new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz')
visit old_path
expect(current_path).to eq(old_path)
- expect(find('h1.title')).to have_content(new_project.name)
+ expect(find('.breadcrumbs')).to have_content(new_project.name)
end
end
end
@@ -120,7 +120,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(group, project)
visit new_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
specify 'the project is accessible via a redirect from the old path' do
@@ -129,7 +129,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(group, project)
visit old_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
context 'and a new project is added with the same path' do
@@ -139,7 +139,7 @@ describe 'Edit Project Settings' do
new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz')
visit old_path
expect(current_path).to eq(old_path)
- expect(find('h1.title')).to have_content(new_project.name)
+ expect(find('.breadcrumbs')).to have_content(new_project.name)
end
end
end
diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb
index aaf64d42515..b2b39dbd24c 100644
--- a/spec/features/projects/sub_group_issuables_spec.rb
+++ b/spec/features/projects/sub_group_issuables_spec.rb
@@ -24,7 +24,7 @@ describe 'Subgroup Issuables', :js, :nested_groups do
end
def expect_to_have_full_subgroup_title
- title = find('.title-container')
+ title = find('.breadcrumbs-links')
expect(title).not_to have_selector '.initializing'
expect(title).to have_content 'group / subgroup / project'
diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb
index 3bf25221e36..9b6864eb90f 100644
--- a/spec/features/reportable_note/commit_spec.rb
+++ b/spec/features/reportable_note/commit_spec.rb
@@ -18,7 +18,7 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'commit'
end
context 'a diff note' do
@@ -28,6 +28,6 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'commit'
end
end
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
index 21e96f6f103..f5a1950e48e 100644
--- a/spec/features/reportable_note/issue_spec.rb
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -13,5 +13,5 @@ describe 'Reportable note on issue', :js do
visit project_issue_path(project, issue)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'issue'
end
diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb
index bb296546e06..1f69257f7ed 100644
--- a/spec/features/reportable_note/merge_request_spec.rb
+++ b/spec/features/reportable_note/merge_request_spec.rb
@@ -15,12 +15,12 @@ describe 'Reportable note on merge request', :js do
context 'a normal note' do
let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) }
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'merge_request'
end
context 'a diff note' do
let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'merge_request'
end
end
diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb
index f1e48ed46be..98ef50b78de 100644
--- a/spec/features/reportable_note/snippets_spec.rb
+++ b/spec/features/reportable_note/snippets_spec.rb
@@ -17,6 +17,6 @@ describe 'Reportable note on snippets', :js do
visit project_snippet_path(project, snippet)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'snippet'
end
end
diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb
index 31d509455ba..05a089641f1 100644
--- a/spec/features/search_spec.rb
+++ b/spec/features/search_spec.rb
@@ -160,7 +160,7 @@ describe "Search" do
fill_in 'search', with: 'gitlab'
find('#search').native.send_keys(:enter)
- page.within '.title' do
+ page.within '.breadcrumbs-sub-title' do
expect(page).to have_content 'Search'
end
end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index 580258f77eb..ff6f71d7528 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -181,7 +181,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -189,15 +189,14 @@ feature 'Task Lists' do
expect(page).to have_selector('.note ul input[checked]', count: 2)
end
- it 'contains the required selectors' do
+ it 'contains the required selectors', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note .js-task-list-container')
expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox')
- expect(page).to have_selector('.note .js-task-list-container .js-task-list-field')
end
- it 'is only editable by author' do
+ it 'is only editable by author', :js do
visit_issue(project, issue)
expect(page).to have_selector('.js-task-list-container')
@@ -215,7 +214,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -230,7 +229,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb
index 53cad623a35..e1c95590af1 100644
--- a/spec/features/uploads/user_uploads_file_to_note_spec.rb
+++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb
@@ -10,6 +10,7 @@ feature 'User uploads file to note' do
before do
sign_in(user)
visit project_issue_path(project, issue)
+ wait_for_requests
end
context 'before uploading' do
diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json
index 2f12b671dec..1030f323a1f 100644
--- a/spec/fixtures/api/schemas/entities/merge_request.json
+++ b/spec/fixtures/api/schemas/entities/merge_request.json
@@ -18,6 +18,8 @@
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["integer", "null"] },
"human_total_time_spent": { "type": ["integer", "null"] },
+ "milestone": { "type": ["object", "null"] },
+ "labels": { "type": ["array", "null"] },
"in_progress_merge_commit_sha": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] },
"merge_commit_sha": { "type": ["string", "null"] },
diff --git a/spec/fixtures/fuzzy.po b/spec/fixtures/fuzzy.po
new file mode 100644
index 00000000000..99b7d12b91a
--- /dev/null
+++ b/spec/fixtures/fuzzy.po
@@ -0,0 +1,27 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-12 12:35-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "1 commit"
+msgid_plural "%d commits"
+msgstr[0] "1 cambio"
+msgstr[1] "%d cambios"
+
+#, fuzzy
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Схема"
diff --git a/spec/fixtures/invalid.po b/spec/fixtures/invalid.po
new file mode 100644
index 00000000000..039a56e9fc0
--- /dev/null
+++ b/spec/fixtures/invalid.po
@@ -0,0 +1,25 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-12 12:35-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
+msgstr[1] "%d cambios"
+
+But this doesn't even look like an PO-entry \ No newline at end of file
diff --git a/spec/fixtures/missing_metadata.po b/spec/fixtures/missing_metadata.po
new file mode 100644
index 00000000000..b1999c933f1
--- /dev/null
+++ b/spec/fixtures/missing_metadata.po
@@ -0,0 +1,4 @@
+msgid "1 commit"
+msgid_plural "%d commits"
+msgstr[0] "1 cambio"
+msgstr[1] "%d cambios"
diff --git a/spec/fixtures/missing_plurals.po b/spec/fixtures/missing_plurals.po
new file mode 100644
index 00000000000..09ca0c82718
--- /dev/null
+++ b/spec/fixtures/missing_plurals.po
@@ -0,0 +1,22 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
diff --git a/spec/fixtures/multiple_plurals.po b/spec/fixtures/multiple_plurals.po
new file mode 100644
index 00000000000..84b17b13ffa
--- /dev/null
+++ b/spec/fixtures/multiple_plurals.po
@@ -0,0 +1,26 @@
+# Arthur Charron <arthur.charron@hotmail.fr>, 2017. #zanata
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Kohei Ota <inductor@kela.jp>, 2017. #zanata
+# Taisuke Inoue <taisuke.inoue.jp@gmail.com>, 2017. #zanata
+# Takuya Noguchi <takninnovationresearch@gmail.com>, 2017. #zanata
+# YANO Tethurou <tetuyano+zana@gmail.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-08-06 11:23-0400\n"
+"Last-Translator: Taisuke Inoue <taisuke.inoue.jp@gmail.com>\n"
+"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/"
+"project/view/GitLab)\n"
+"Language: ja\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=3; plural=n\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d個のコミット"
+msgstr[1] "%d個のコミット"
+msgstr[2] "missing a variable"
diff --git a/spec/fixtures/newlines.po b/spec/fixtures/newlines.po
new file mode 100644
index 00000000000..f5bc86f39a7
--- /dev/null
+++ b/spec/fixtures/newlines.po
@@ -0,0 +1,48 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-12 12:35-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "1 commit"
+msgid_plural "%d commits"
+msgstr[0] "1 cambio"
+msgstr[1] "%d cambios"
+
+msgid ""
+"You are going to remove %{group_name}.\n"
+"Removed groups CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Va a eliminar %{group_name}.\n"
+"¡El grupo eliminado NO puede ser restaurado!\n"
+"¿Estás TOTALMENTE seguro?"
+
+msgid "With plural"
+msgid_plural "with plurals"
+msgstr[0] "first"
+msgstr[1] "second"
+msgstr[2] ""
+"with"
+"multiple"
+"lines"
+
+msgid "multiline plural id"
+msgid_plural ""
+"Plural"
+"Id"
+msgstr[0] "first"
+msgstr[1] "second"
diff --git a/spec/fixtures/unescaped_chars.po b/spec/fixtures/unescaped_chars.po
new file mode 100644
index 00000000000..fbafe523fb3
--- /dev/null
+++ b/spec/fixtures/unescaped_chars.po
@@ -0,0 +1,21 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"
diff --git a/spec/fixtures/valid.po b/spec/fixtures/valid.po
new file mode 100644
index 00000000000..e43fd5fea15
--- /dev/null
+++ b/spec/fixtures/valid.po
@@ -0,0 +1,1136 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
+msgstr[1] "%d cambios"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural "%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "%s cambio adicional ha sido omitido para evitar problemas de rendimiento."
+msgstr[1] "%s cambios adicionales han sido omitidos para evitar problemas de rendimiento."
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} cambió %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipelines"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Una colección de gráficos sobre Integración Continua"
+
+msgid "About auto deploy"
+msgstr "Acerca del auto despliegue"
+
+msgid "Active"
+msgstr "Activo"
+
+msgid "Activity"
+msgstr "Actividad"
+
+msgid "Add Changelog"
+msgstr "Agregar Changelog"
+
+msgid "Add Contribution guide"
+msgstr "Agregar guía de contribución"
+
+msgid "Add License"
+msgstr "Agregar Licencia"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH."
+
+msgid "Add new directory"
+msgstr "Agregar nuevo directorio"
+
+msgid "Archived project! Repository is read-only"
+msgstr "¡Proyecto archivado! El repositorio es de solo lectura"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "¿Estás seguro que deseas eliminar esta programación del pipeline?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Adjunte un archivo arrastrando &amp; soltando o %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Rama"
+msgstr[1] "Ramas"
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr "La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Buscar ramas"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Cambiar rama"
+
+msgid "Branches"
+msgstr "Ramas"
+
+msgid "Browse Directory"
+msgstr "Examinar directorio"
+
+msgid "Browse File"
+msgstr "Examinar archivo"
+
+msgid "Browse Files"
+msgstr "Examinar archivos"
+
+msgid "Browse files"
+msgstr "Examinar archivos"
+
+msgid "ByAuthor|by"
+msgstr "por"
+
+msgid "CI configuration"
+msgstr "Configuración de CI"
+
+msgid "Cancel"
+msgstr "Cancelar"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Escoger en la rama"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Revertir en la rama"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Revertir"
+
+msgid "Changelog"
+msgstr "Changelog"
+
+msgid "Charts"
+msgstr "Gráficos"
+
+msgid "Cherry-pick this commit"
+msgstr "Escoger este cambio"
+
+msgid "Cherry-pick this merge request"
+msgstr "Escoger esta solicitud de fusión"
+
+msgid "CiStatusLabel|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusLabel|created"
+msgstr "creado"
+
+msgid "CiStatusLabel|failed"
+msgstr "fallido"
+
+msgid "CiStatusLabel|manual action"
+msgstr "acción manual"
+
+msgid "CiStatusLabel|passed"
+msgstr "pasó"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "pasó con advertencias"
+
+msgid "CiStatusLabel|pending"
+msgstr "pendiente"
+
+msgid "CiStatusLabel|skipped"
+msgstr "omitido"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "esperando acción manual"
+
+msgid "CiStatusText|blocked"
+msgstr "bloqueado"
+
+msgid "CiStatusText|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusText|created"
+msgstr "creado"
+
+msgid "CiStatusText|failed"
+msgstr "fallado"
+
+msgid "CiStatusText|manual"
+msgstr "manual"
+
+msgid "CiStatusText|passed"
+msgstr "pasó"
+
+msgid "CiStatusText|pending"
+msgstr "pendiente"
+
+msgid "CiStatusText|skipped"
+msgstr "omitido"
+
+msgid "CiStatus|running"
+msgstr "en ejecución"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Cambio"
+msgstr[1] "Cambios"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Duración de los cambios en minutos para los últimos 30"
+
+msgid "Commit message"
+msgstr "Mensaje del cambio"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Cambio"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Agregar %{file_name}"
+
+msgid "Commits"
+msgstr "Cambios"
+
+msgid "Commits feed"
+msgstr "Feed de cambios"
+
+msgid "Commits|History"
+msgstr "Historial"
+
+msgid "Committed by"
+msgstr "Enviado por"
+
+msgid "Compare"
+msgstr "Comparar"
+
+msgid "Contribution guide"
+msgstr "Guía de contribución"
+
+msgid "Contributors"
+msgstr "Contribuidores"
+
+msgid "Copy URL to clipboard"
+msgstr "Copiar URL al portapapeles"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Copiar SHA del cambio al portapapeles"
+
+msgid "Create New Directory"
+msgstr "Crear Nuevo Directorio"
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr "Crear un token de acceso personal en tu cuenta para actualizar o enviar a través de %{protocol}."
+
+msgid "Create directory"
+msgstr "Crear directorio"
+
+msgid "Create empty bare repository"
+msgstr "Crear repositorio vacío"
+
+msgid "Create merge request"
+msgstr "Crear solicitud de fusión"
+
+msgid "Create new..."
+msgstr "Crear nuevo..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Bifurcar"
+
+msgid "CreateTag|Tag"
+msgstr "Etiqueta"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "crear un token de acceso personal"
+
+msgid "Cron Timezone"
+msgstr "Zona horaria del Cron"
+
+msgid "Cron syntax"
+msgstr "Sintaxis de Cron"
+
+msgid "Custom notification events"
+msgstr "Eventos de notificaciones personalizadas"
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr "Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Cycle Analytics"
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Código"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Incidencia"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Planificación"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Producción"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Revisión"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Puesta en escena"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Pruebas"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Definir un patrón personalizado con la sintaxis de cron"
+
+msgid "Delete"
+msgstr "Eliminar"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Despliegue"
+msgstr[1] "Despliegues"
+
+msgid "Description"
+msgstr "Descripción"
+
+msgid "Directory name"
+msgstr "Nombre del directorio"
+
+msgid "Don't show again"
+msgstr "No mostrar de nuevo"
+
+msgid "Download"
+msgstr "Descargar"
+
+msgid "Download tar"
+msgstr "Descargar tar"
+
+msgid "Download tar.bz2"
+msgstr "Descargar tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Descargar tar.gz"
+
+msgid "Download zip"
+msgstr "Descargar zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Descargar"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Parches por correo electrónico"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Diferencias en texto plano"
+
+msgid "DownloadSource|Download"
+msgstr "Descargar"
+
+msgid "Edit"
+msgstr "Editar"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Editar Programación del Pipeline %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Todos los días (a las 4:00 am)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Todos los meses (el día 1 a las 4:00 am)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Todas las semanas (domingos a las 4:00 am)"
+
+msgid "Failed to change the owner"
+msgstr "Error al cambiar el propietario"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Error al eliminar la programación del pipeline"
+
+msgid "Files"
+msgstr "Archivos"
+
+msgid "Filter by commit message"
+msgstr "Filtrar por mensaje del cambio"
+
+msgid "Find by path"
+msgstr "Buscar por ruta"
+
+msgid "Find file"
+msgstr "Buscar archivo"
+
+msgid "FirstPushedBy|First"
+msgstr "Primer"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "enviado por"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Bifurcación"
+msgstr[1] "Bifurcaciones"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Bifurcado de"
+
+msgid "From issue creation until deploy to production"
+msgstr "Desde la creación de la incidencia hasta el despliegue a producción"
+
+msgid "From merge request merge until deploy to production"
+msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción"
+
+msgid "Go to your fork"
+msgstr "Ir a tu bifurcación"
+
+msgid "GoToYourFork|Fork"
+msgstr "Bifurcación"
+
+msgid "Home"
+msgstr "Inicio"
+
+msgid "Housekeeping successfully started"
+msgstr "Servicio de limpieza iniciado con éxito"
+
+msgid "Import repository"
+msgstr "Importar repositorio"
+
+msgid "Interval Pattern"
+msgstr "Patrón de intervalo"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Introducción a Cycle Analytics"
+
+msgid "Jobs for last month"
+msgstr "Trabajos del mes pasado"
+
+msgid "Jobs for last week"
+msgstr "Trabajos de la semana pasada"
+
+msgid "Jobs for last year"
+msgstr "Trabajos del año pasado"
+
+msgid "LFSStatus|Disabled"
+msgstr "Deshabilitado"
+
+msgid "LFSStatus|Enabled"
+msgstr "Habilitado"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Último %d día"
+msgstr[1] "Últimos %d días"
+
+msgid "Last Pipeline"
+msgstr "Último Pipeline"
+
+msgid "Last Update"
+msgstr "Última actualización"
+
+msgid "Last commit"
+msgstr "Último cambio"
+
+msgid "Learn more in the"
+msgstr "Más información en la"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "documentación sobre la programación de pipelines"
+
+msgid "Leave group"
+msgstr "Abandonar grupo"
+
+msgid "Leave project"
+msgstr "Abandonar proyecto"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limitado a mostrar máximo %d evento"
+msgstr[1] "Limitado a mostrar máximo %d eventos"
+
+msgid "Median"
+msgstr "Mediana"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "agregar una clave SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nueva incidencia"
+msgstr[1] "Nuevas incidencias"
+
+msgid "New Pipeline Schedule"
+msgstr "Nueva Programación del Pipeline"
+
+msgid "New branch"
+msgstr "Nueva rama"
+
+msgid "New directory"
+msgstr "Nuevo directorio"
+
+msgid "New file"
+msgstr "Nuevo archivo"
+
+msgid "New issue"
+msgstr "Nueva incidencia"
+
+msgid "New merge request"
+msgstr "Nueva solicitud de fusión"
+
+msgid "New schedule"
+msgstr "Nueva programación"
+
+msgid "New snippet"
+msgstr "Nuevo fragmento de código"
+
+msgid "New tag"
+msgstr "Nueva etiqueta"
+
+msgid "No repository"
+msgstr "No hay repositorio"
+
+msgid "No schedules"
+msgstr "No hay programaciones"
+
+msgid "Not available"
+msgstr "No disponible"
+
+msgid "Not enough data"
+msgstr "No hay suficientes datos"
+
+msgid "Notification events"
+msgstr "Eventos de notificación"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Cerrar incidencia"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Cerrar solicitud de fusión"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Pipeline fallido"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Integrar solicitud de fusión"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nueva incidencia"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Nueva solicitud de fusión"
+
+msgid "NotificationEvent|New note"
+msgstr "Nueva nota"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Reasignar incidencia"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Reasignar solicitud de fusión"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Reabrir incidencia"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Pipeline exitoso"
+
+msgid "NotificationLevel|Custom"
+msgstr "Personalizado"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Deshabilitado"
+
+msgid "NotificationLevel|Global"
+msgstr "Global"
+
+msgid "NotificationLevel|On mention"
+msgstr "Cuando me mencionan"
+
+msgid "NotificationLevel|Participate"
+msgstr "Participación"
+
+msgid "NotificationLevel|Watch"
+msgstr "Vigilancia"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtrar"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Abierto"
+
+msgid "Options"
+msgstr "Opciones"
+
+msgid "Owner"
+msgstr "Propietario"
+
+msgid "Pipeline"
+msgstr "Pipeline"
+
+msgid "Pipeline Health"
+msgstr "Estado del Pipeline"
+
+msgid "Pipeline Schedule"
+msgstr "Programación del Pipeline"
+
+msgid "Pipeline Schedules"
+msgstr "Programaciones de los Pipelines"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Fallidos:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Estadísticas generales"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Ratio de éxito"
+
+msgid "PipelineCharts|Successful:"
+msgstr "Exitosos:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Total:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Activado"
+
+msgid "PipelineSchedules|Active"
+msgstr "Activos"
+
+msgid "PipelineSchedules|All"
+msgstr "Todos"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Inactivos"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Ingrese nombre de clave"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Ingrese el valor de la variable"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Próxima Ejecución"
+
+msgid "PipelineSchedules|None"
+msgstr "Ninguno"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Proporcione una descripción breve para este pipeline"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Eliminar fila de variable"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Tomar posesión"
+
+msgid "PipelineSchedules|Target"
+msgstr "Destino"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variables"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Personalizado"
+
+msgid "Pipelines"
+msgstr "Pipelines"
+
+msgid "Pipelines charts"
+msgstr "Gráficos de los pipelines"
+
+msgid "Pipeline|all"
+msgstr "todos"
+
+msgid "Pipeline|success"
+msgstr "exitósos"
+
+msgid "Pipeline|with stage"
+msgstr "con etapa"
+
+msgid "Pipeline|with stages"
+msgstr "con etapas"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Proyecto ‘%{project_name}’ en cola para eliminación."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Proyecto ‘%{project_name}’ fue creado satisfactoriamente."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Proyecto ‘%{project_name}’ será eliminado."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario."
+
+msgid "Project export could not be deleted."
+msgstr "No se pudo eliminar la exportación del proyecto."
+
+msgid "Project export has been deleted."
+msgstr "La exportación del proyecto ha sido eliminada."
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr "El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico."
+
+msgid "Project home"
+msgstr "Inicio del proyecto"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Deshabilitada"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Todos con acceso"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Solo miembros del equipo"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nombre"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Nunca"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Etapa"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Historial gráfico"
+
+msgid "Read more"
+msgstr "Leer más"
+
+msgid "Readme"
+msgstr "Léeme"
+
+msgid "RefSwitcher|Branches"
+msgstr "Ramas"
+
+msgid "RefSwitcher|Tags"
+msgstr "Etiquetas"
+
+msgid "Related Commits"
+msgstr "Cambios Relacionados"
+
+msgid "Related Deployed Jobs"
+msgstr "Trabajos Desplegados Relacionados"
+
+msgid "Related Issues"
+msgstr "Incidencias Relacionadas"
+
+msgid "Related Jobs"
+msgstr "Trabajos Relacionados"
+
+msgid "Related Merge Requests"
+msgstr "Solicitudes de fusión Relacionadas"
+
+msgid "Related Merged Requests"
+msgstr "Solicitudes de fusión Relacionadas"
+
+msgid "Remind later"
+msgstr "Recordar después"
+
+msgid "Remove project"
+msgstr "Eliminar proyecto"
+
+msgid "Request Access"
+msgstr "Solicitar acceso"
+
+msgid "Revert this commit"
+msgstr "Revertir este cambio"
+
+msgid "Revert this merge request"
+msgstr "Revertir esta solicitud de fusión"
+
+msgid "Save pipeline schedule"
+msgstr "Guardar programación del pipeline"
+
+msgid "Schedule a new pipeline"
+msgstr "Programar un nuevo pipeline"
+
+msgid "Scheduling Pipelines"
+msgstr "Programación de Pipelines"
+
+msgid "Search branches and tags"
+msgstr "Buscar ramas y etiquetas"
+
+msgid "Select Archive Format"
+msgstr "Seleccionar formato de archivo"
+
+msgid "Select a timezone"
+msgstr "Selecciona una zona horaria"
+
+msgid "Select target branch"
+msgstr "Selecciona una rama de destino"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "Establezca una contraseña en su cuenta para actualizar o enviar a través de %{protocol}."
+
+msgid "Set up CI"
+msgstr "Configurar CI"
+
+msgid "Set up Koding"
+msgstr "Configurar Koding"
+
+msgid "Set up auto deploy"
+msgstr "Configurar auto despliegue"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "establecer una contraseña"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Mostrando %d evento"
+msgstr[1] "Mostrando %d eventos"
+
+msgid "Source code"
+msgstr "Código fuente"
+
+msgid "StarProject|Star"
+msgstr "Destacar"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Iniciar una %{new_merge_request} con estos cambios"
+
+msgid "Switch branch/tag"
+msgstr "Cambiar rama/etiqueta"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Etiqueta"
+msgstr[1] "Etiquetas"
+
+msgid "Tags"
+msgstr "Etiquetas"
+
+msgid "Target Branch"
+msgstr "Rama de destino"
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "La colección de eventos agregados a los datos recopilados para esa etapa."
+
+msgid "The fork relationship has been removed."
+msgstr "La relación con la bifurcación se ha eliminado."
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."
+
+msgid "The phase of the development lifecycle."
+msgstr "La etapa del ciclo de vida de desarrollo."
+
+msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user."
+msgstr "La programación de pipelines ejecuta pipelines en el futuro, repetidamente, para ramas o etiquetas específicas. Los pipelines programados heredarán acceso limitado al proyecto basado en su usuario asociado."
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "El proyecto puede ser accedido por cualquier usuario conectado."
+
+msgid "The project can be accessed without any authentication."
+msgstr "El proyecto puede accederse sin ninguna autenticación."
+
+msgid "The repository for this project does not exist."
+msgstr "El repositorio para este proyecto no existe."
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa."
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr "Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Tiempo antes de que una incidencia sea programada"
+
+msgid "Time before an issue starts implementation"
+msgstr "Tiempo antes de que empieze la implementación de una incidencia"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"
+
+msgid "Time until first merge request"
+msgstr "Tiempo hasta la primera solicitud de fusión"
+
+msgid "Timeago|%s days ago"
+msgstr "hace %s días"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s días restantes"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s horas restantes"
+
+msgid "Timeago|%s minutes ago"
+msgstr "hace %s minutos"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s minutos restantes"
+
+msgid "Timeago|%s months ago"
+msgstr "hace %s meses"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s meses restantes"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s segundos restantes"
+
+msgid "Timeago|%s weeks ago"
+msgstr "hace %s semanas"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s semanas restantes"
+
+msgid "Timeago|%s years ago"
+msgstr "hace %s años"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s años restantes"
+
+msgid "Timeago|1 day remaining"
+msgstr "1 día restante"
+
+msgid "Timeago|1 hour remaining"
+msgstr "1 hora restante"
+
+msgid "Timeago|1 minute remaining"
+msgstr "1 minuto restante"
+
+msgid "Timeago|1 month remaining"
+msgstr "1 mes restante"
+
+msgid "Timeago|1 week remaining"
+msgstr "1 semana restante"
+
+msgid "Timeago|1 year remaining"
+msgstr "1 año restante"
+
+msgid "Timeago|Past due"
+msgstr "Atrasado"
+
+msgid "Timeago|a day ago"
+msgstr "hace un día"
+
+msgid "Timeago|a month ago"
+msgstr "hace un mes"
+
+msgid "Timeago|a week ago"
+msgstr "hace una semana"
+
+msgid "Timeago|a while"
+msgstr "hace un momento"
+
+msgid "Timeago|a year ago"
+msgstr "hace un año"
+
+msgid "Timeago|about %s hours ago"
+msgstr "hace alrededor de %s horas"
+
+msgid "Timeago|about a minute ago"
+msgstr "hace alrededor de 1 minuto"
+
+msgid "Timeago|about an hour ago"
+msgstr "hace alrededor de 1 hora"
+
+msgid "Timeago|in %s days"
+msgstr "en %s días"
+
+msgid "Timeago|in %s hours"
+msgstr "en %s horas"
+
+msgid "Timeago|in %s minutes"
+msgstr "en %s minutos"
+
+msgid "Timeago|in %s months"
+msgstr "en %s meses"
+
+msgid "Timeago|in %s seconds"
+msgstr "en %s segundos"
+
+msgid "Timeago|in %s weeks"
+msgstr "en %s semanas"
+
+msgid "Timeago|in %s years"
+msgstr "en %s años"
+
+msgid "Timeago|in 1 day"
+msgstr "en 1 día"
+
+msgid "Timeago|in 1 hour"
+msgstr "en 1 hora"
+
+msgid "Timeago|in 1 minute"
+msgstr "en 1 minuto"
+
+msgid "Timeago|in 1 month"
+msgstr "en 1 mes"
+
+msgid "Timeago|in 1 week"
+msgstr "en 1 semana"
+
+msgid "Timeago|in 1 year"
+msgstr "en 1 año"
+
+msgid "Timeago|less than a minute ago"
+msgstr "hace menos de 1 minuto"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "hr"
+msgstr[1] "hrs"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Tiempo Total"
+
+msgid "Total test time for all commits/merges"
+msgstr "Tiempo total de pruebas para todos los cambios o integraciones"
+
+msgid "Unstar"
+msgstr "No Destacar"
+
+msgid "Upload New File"
+msgstr "Subir nuevo archivo"
+
+msgid "Upload file"
+msgstr "Subir archivo"
+
+msgid "UploadLink|click to upload"
+msgstr "Hacer clic para subir"
+
+msgid "Use your global notification setting"
+msgstr "Utiliza tu configuración de notificación global"
+
+msgid "View open merge request"
+msgstr "Ver solicitud de fusión abierta"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interno"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privado"
+
+msgid "VisibilityLevel|Public"
+msgstr "Público"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Desconocido"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador."
+
+msgid "We don't have enough data to show this stage."
+msgstr "No hay suficientes datos para mostrar en esta etapa."
+
+msgid "Withdraw Access Request"
+msgstr "Retirar Solicitud de Acceso"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Va a eliminar %{group_name}. ¡El grupo eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Va a eliminar %{project_name_with_namespace}. ¡El proyecto eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Solo puedes agregar archivos cuando estás en una rama"
+
+msgid "You have reached your project limit"
+msgstr "Has alcanzado el límite de tu proyecto"
+
+msgid "You must sign in to star a project"
+msgstr "Debes iniciar sesión para destacar un proyecto"
+
+msgid "You need permission."
+msgstr "Necesitas permisos."
+
+msgid "You will not get any notifications via email"
+msgstr "No recibirás ninguna notificación por correo electrónico"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Solo recibirás notificaciones de los eventos que elijas"
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr "Solo recibirás notificaciones de los temas en los que has participado"
+
+msgid "You will receive notifications for any activity"
+msgstr "Recibirás notificaciones por cualquier actividad"
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr "Recibirás notificaciones solo para los comentarios en los que se te mencionó"
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+msgstr "No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta"
+
+msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
+msgstr "No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil"
+
+msgid "Your name"
+msgstr "Tu nombre"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "día"
+msgstr[1] "días"
+
+msgid "new merge request"
+msgstr "nueva solicitud de fusión"
+
+msgid "notification emails"
+msgstr "correos electrónicos de notificación"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "padre"
+msgstr[1] "padres"
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 7789cfa3554..ead3e28438e 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -59,112 +59,6 @@ describe IssuablesHelper do
.to eq('<span>All</span> <span class="badge">42</span>')
end
end
-
- describe 'counter caching based on issuable type and params', :use_clean_rails_memory_store_caching do
- let(:params) do
- {
- scope: 'created-by-me',
- state: 'opened',
- utf8: '✓',
- author_id: '11',
- assignee_id: '18',
- label_name: %w(bug discussion documentation),
- milestone_title: 'v4.0',
- sort: 'due_date_asc',
- namespace_id: 'gitlab-org',
- project_id: 'gitlab-ce',
- page: 2
- }.with_indifferent_access
- end
-
- let(:issues_finder) { IssuesFinder.new(nil, params) }
- let(:merge_requests_finder) { MergeRequestsFinder.new(nil, params) }
-
- before do
- allow(helper).to receive(:issues_finder).and_return(issues_finder)
- allow(helper).to receive(:merge_requests_finder).and_return(merge_requests_finder)
- end
-
- it 'returns the cached value when called for the same issuable type & with the same params' do
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('<span>Open</span> <span class="badge">42</span>')
-
- expect(issues_finder).not_to receive(:count_by_state)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('<span>Open</span> <span class="badge">42</span>')
- end
-
- it 'takes confidential status into account when searching for issues' do
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to include('42')
-
- expect(issues_finder).to receive(:user_cannot_see_confidential_issues?).twice.and_return(false)
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 40)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to include('40')
-
- expect(issues_finder).to receive(:user_can_see_all_confidential_issues?).and_return(true)
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 45)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to include('45')
- end
-
- it 'does not take confidential status into account when searching for merge requests' do
- expect(merge_requests_finder).to receive(:count_by_state).and_return(opened: 42)
- expect(merge_requests_finder).not_to receive(:user_cannot_see_confidential_issues?)
- expect(merge_requests_finder).not_to receive(:user_can_see_all_confidential_issues?)
-
- expect(helper.issuables_state_counter_text(:merge_requests, :opened))
- .to include('42')
- end
-
- it 'does not take some keys into account in the cache key' do
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
- expect(issues_finder).to receive(:params).and_return({
- author_id: '11',
- state: 'foo',
- sort: 'foo',
- utf8: 'foo',
- page: 'foo'
- }.with_indifferent_access)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('<span>Open</span> <span class="badge">42</span>')
-
- expect(issues_finder).not_to receive(:count_by_state)
- expect(issues_finder).to receive(:params).and_return({
- author_id: '11',
- state: 'bar',
- sort: 'bar',
- utf8: 'bar',
- page: 'bar'
- }.with_indifferent_access)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('<span>Open</span> <span class="badge">42</span>')
- end
-
- it 'does not take params order into account in the cache key' do
- expect(issues_finder).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('<span>Open</span> <span class="badge">42</span>')
-
- expect(issues_finder).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
- expect(issues_finder).not_to receive(:count_by_state)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('<span>Open</span> <span class="badge">42</span>')
- end
- end
end
describe '#issuable_reference' do
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index 8e056882108..a22b71fd1dc 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -25,9 +25,10 @@ import '~/lib/utils/common_utils';
};
describe('AwardsHandler', function() {
- preloadFixtures('issues/issue_with_comment.html.raw');
+ preloadFixtures('merge_requests/diff_comment.html.raw');
beforeEach(function(done) {
- loadFixtures('issues/issue_with_comment.html.raw');
+ loadFixtures('merge_requests/diff_comment.html.raw');
+ $('body').data('page', 'projects:merge_requests:show');
loadAwardsHandler(true).then((obj) => {
awardsHandler = obj;
spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
@@ -139,7 +140,7 @@ import '~/lib/utils/common_utils';
});
describe('::getAwardUrl', function() {
return it('returns the url for request', function() {
- return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/issues-project/issues/1/toggle_award_emoji');
+ return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1/toggle_award_emoji');
});
});
describe('::addAward and ::checkMutuality', function() {
diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js
index 6dc48f9a293..f62bf43adb9 100644
--- a/spec/javascripts/behaviors/quick_submit_spec.js
+++ b/spec/javascripts/behaviors/quick_submit_spec.js
@@ -1,119 +1,111 @@
-/* eslint-disable space-before-function-paren, no-var, no-return-assign, comma-dangle, jasmine/no-spec-dupes, new-cap, max-len */
-
import '~/behaviors/quick_submit';
-(function() {
- describe('Quick Submit behavior', function() {
- var keydownEvent;
- preloadFixtures('issues/open-issue.html.raw');
- beforeEach(function() {
- loadFixtures('issues/open-issue.html.raw');
- $('form').submit(function(e) {
- // Prevent a form submit from moving us off the testing page
- return e.preventDefault();
- });
- this.spies = {
- submit: spyOnEvent('form', 'submit')
- };
+describe('Quick Submit behavior', () => {
+ const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
- this.textarea = $('.js-quick-submit textarea').first();
- });
- it('does not respond to other keyCodes', function() {
- this.textarea.trigger(keydownEvent({
- keyCode: 32
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- it('does not respond to Enter alone', function() {
- this.textarea.trigger(keydownEvent({
- ctrlKey: false,
- metaKey: false
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- it('does not respond to repeated events', function() {
- this.textarea.trigger(keydownEvent({
- repeat: true
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- it('disables input of type submit', function() {
- const submitButton = $('.js-quick-submit input[type=submit]');
- this.textarea.trigger(keydownEvent());
+ preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
- expect(submitButton).toBeDisabled();
+ beforeEach(() => {
+ loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
+ $('body').attr('data-page', 'projects:merge_requests:show');
+ $('form').submit((e) => {
+ // Prevent a form submit from moving us off the testing page
+ e.preventDefault();
});
- it('disables button of type submit', function() {
- const submitButton = $('.js-quick-submit input[type=submit]');
- this.textarea.trigger(keydownEvent());
+ this.spies = {
+ submit: spyOnEvent('form', 'submit'),
+ };
- expect(submitButton).toBeDisabled();
- });
- it('only clicks one submit', function() {
- const existingSubmit = $('.js-quick-submit input[type=submit]');
- // Add an extra submit button
- const newSubmit = $('<button type="submit">Submit it</button>');
- newSubmit.insertAfter(this.textarea);
+ this.textarea = $('.js-quick-submit textarea').first();
+ });
- const oldClick = spyOnEvent(existingSubmit, 'click');
- const newClick = spyOnEvent(newSubmit, 'click');
+ it('does not respond to other keyCodes', () => {
+ this.textarea.trigger(keydownEvent({
+ keyCode: 32,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
- this.textarea.trigger(keydownEvent());
+ it('does not respond to Enter alone', () => {
+ this.textarea.trigger(keydownEvent({
+ ctrlKey: false,
+ metaKey: false,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
- expect(oldClick).not.toHaveBeenTriggered();
- expect(newClick).toHaveBeenTriggered();
- });
- // We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll
- // only run the tests that apply to the current platform
- if (navigator.userAgent.match(/Macintosh/)) {
- it('responds to Meta+Enter', function() {
- this.textarea.trigger(keydownEvent());
- return expect(this.spies.submit).toHaveBeenTriggered();
- });
- it('excludes other modifier keys', function() {
- this.textarea.trigger(keydownEvent({
- altKey: true
- }));
- this.textarea.trigger(keydownEvent({
- ctrlKey: true
- }));
- this.textarea.trigger(keydownEvent({
- shiftKey: true
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- } else {
- it('responds to Ctrl+Enter', function() {
+ it('does not respond to repeated events', () => {
+ this.textarea.trigger(keydownEvent({
+ repeat: true,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+
+ it('disables input of type submit', () => {
+ const submitButton = $('.js-quick-submit input[type=submit]');
+ this.textarea.trigger(keydownEvent());
+
+ expect(submitButton).toBeDisabled();
+ });
+ it('disables button of type submit', () => {
+ const submitButton = $('.js-quick-submit input[type=submit]');
+ this.textarea.trigger(keydownEvent());
+
+ expect(submitButton).toBeDisabled();
+ });
+ it('only clicks one submit', () => {
+ const existingSubmit = $('.js-quick-submit input[type=submit]');
+ // Add an extra submit button
+ const newSubmit = $('<button type="submit">Submit it</button>');
+ newSubmit.insertAfter(this.textarea);
+
+ const oldClick = spyOnEvent(existingSubmit, 'click');
+ const newClick = spyOnEvent(newSubmit, 'click');
+
+ this.textarea.trigger(keydownEvent());
+
+ expect(oldClick).not.toHaveBeenTriggered();
+ expect(newClick).toHaveBeenTriggered();
+ });
+ // We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll
+ // only run the tests that apply to the current platform
+ if (navigator.userAgent.match(/Macintosh/)) {
+ describe('In Macintosh', () => {
+ it('responds to Meta+Enter', () => {
this.textarea.trigger(keydownEvent());
return expect(this.spies.submit).toHaveBeenTriggered();
});
- it('excludes other modifier keys', function() {
+
+ it('excludes other modifier keys', () => {
this.textarea.trigger(keydownEvent({
- altKey: true
+ altKey: true,
}));
this.textarea.trigger(keydownEvent({
- metaKey: true
+ ctrlKey: true,
}));
this.textarea.trigger(keydownEvent({
- shiftKey: true
+ shiftKey: true,
}));
return expect(this.spies.submit).not.toHaveBeenTriggered();
});
- }
- return keydownEvent = function(options) {
- var defaults;
- if (navigator.userAgent.match(/Macintosh/)) {
- defaults = {
- keyCode: 13,
- metaKey: true
- };
- } else {
- defaults = {
- keyCode: 13,
- ctrlKey: true
- };
- }
- return $.Event('keydown', $.extend({}, defaults, options));
- };
- });
-}).call(window);
+ });
+ } else {
+ it('responds to Ctrl+Enter', () => {
+ this.textarea.trigger(keydownEvent());
+ return expect(this.spies.submit).toHaveBeenTriggered();
+ });
+
+ it('excludes other modifier keys', () => {
+ this.textarea.trigger(keydownEvent({
+ altKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ metaKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ shiftKey: true,
+ }));
+ return expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+ }
+});
diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb
index 767db7e1868..4bc2205e642 100644
--- a/spec/javascripts/fixtures/merge_requests.rb
+++ b/spec/javascripts/fixtures/merge_requests.rb
@@ -59,6 +59,11 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request)
end
+ it 'merge_requests/merge_request_with_comment.html.raw' do |example|
+ create(:note_on_merge_request, author: admin, project: project, noteable: merge_request, note: '- [ ] Task List Item')
+ render_merge_request(example.description, merge_request)
+ end
+
private
def render_merge_request(fixture_file_name, merge_request)
diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js
index 0847e463577..4588bf3d971 100644
--- a/spec/javascripts/fly_out_nav_spec.js
+++ b/spec/javascripts/fly_out_nav_spec.js
@@ -5,12 +5,14 @@ import {
canShowActiveSubItems,
mouseEnterTopItems,
mouseLeaveTopItem,
+ getOpenMenu,
setOpenMenu,
mousePos,
getHideSubItemsInterval,
documentMouseMove,
getHeaderHeight,
setSidebar,
+ subItemsMouseLeave,
} from '~/fly_out_nav';
import bp from '~/breakpoints';
@@ -314,4 +316,29 @@ describe('Fly out sidebar navigation', () => {
).toBeTruthy();
});
});
+
+ describe('subItemsMouseLeave', () => {
+ beforeEach(() => {
+ el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+
+ setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+ });
+
+ it('hides subMenu if element is not hovered', () => {
+ subItemsMouseLeave(el);
+
+ expect(
+ getOpenMenu(),
+ ).toBeNull();
+ });
+
+ it('does not hide subMenu if element is hovered', () => {
+ el.classList.add('is-over');
+ subItemsMouseLeave(el);
+
+ expect(
+ getOpenMenu(),
+ ).not.toBeNull();
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 81ce18bf2fb..3af26e2f28f 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -41,9 +41,9 @@ describe('Issuable output', () => {
initialTitleText: '',
initialDescriptionHtml: '',
initialDescriptionText: '',
- markdownPreviewUrl: '/',
- markdownDocs: '/',
- projectsAutocompleteUrl: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectsAutocompletePath: '/',
isConfidential: false,
projectNamespace: '/',
projectPath: '/',
diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js
index df8189d9290..299f88e7778 100644
--- a/spec/javascripts/issue_show/components/fields/description_spec.js
+++ b/spec/javascripts/issue_show/components/fields/description_spec.js
@@ -25,8 +25,8 @@ describe('Description field component', () => {
vm = new Component({
el,
propsData: {
- markdownPreviewUrl: '/',
- markdownDocs: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
formState: store.formState,
},
}).$mount();
diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js
index 86d35c33ff4..8b6ed6a03a9 100644
--- a/spec/javascripts/issue_show/components/fields/project_move_spec.js
+++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js
@@ -15,7 +15,7 @@ describe('Project move field component', () => {
vm = new Component({
propsData: {
formState,
- projectsAutocompleteUrl: '/autocomplete',
+ projectsAutocompletePath: '/autocomplete',
},
}).$mount();
diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js
index 9a85223208c..d8af5287431 100644
--- a/spec/javascripts/issue_show/components/form_spec.js
+++ b/spec/javascripts/issue_show/components/form_spec.js
@@ -18,9 +18,9 @@ describe('Inline edit form component', () => {
description: 'a',
lockedWarningVisible: false,
},
- markdownPreviewUrl: '/',
- markdownDocs: '/',
- projectsAutocompleteUrl: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectsAutocompletePath: '/',
projectPath: '/',
projectNamespace: '/',
},
diff --git a/spec/javascripts/notes/components/issue_comment_form_spec.js b/spec/javascripts/notes/components/issue_comment_form_spec.js
new file mode 100644
index 00000000000..cca5ec887a3
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_comment_form_spec.js
@@ -0,0 +1,134 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueCommentForm from '~/notes/components/issue_comment_form.vue';
+import { loggedOutIssueData, notesDataMock, userDataMock, issueDataMock } from '../mock_data';
+import { keyboardDownEvent } from '../../issue_show/helpers';
+
+describe('issue_comment_form component', () => {
+ let vm;
+ const Component = Vue.extend(issueCommentForm);
+ let mountComponent;
+
+ beforeEach(() => {
+ mountComponent = () => new Component({
+ store,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user is logged in', () => {
+ beforeEach(() => {
+ store.dispatch('setUserData', userDataMock);
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = mountComponent();
+ });
+
+ it('should render user avatar with link', () => {
+ expect(vm.$el.querySelector('.timeline-icon .user-avatar-link').getAttribute('href')).toEqual(userDataMock.path);
+ });
+
+ describe('textarea', () => {
+ it('should render textarea with placeholder', () => {
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+
+ it('should support quick actions', () => {
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('data-supports-quick-actions'),
+ ).toEqual('true');
+ });
+
+ it('should link to markdown docs', () => {
+ const { markdownDocsPath } = notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
+ });
+
+ it('should link to quick actions docs', () => {
+ const { quickActionsDocsPath } = notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions');
+ });
+
+ describe('edit mode', () => {
+ it('should enter edit mode when arrow up is pressed', () => {
+ spyOn(vm, 'editCurrentUserLastNote').and.callThrough();
+ vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
+ vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(38, true));
+
+ expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
+ });
+ });
+
+ describe('event enter', () => {
+ it('should save note when cmd/ctrl+enter is pressed', () => {
+ spyOn(vm, 'handleSave').and.callThrough();
+ vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
+ vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(vm.handleSave).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('actions', () => {
+ it('should be possible to close the issue', () => {
+ expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close issue');
+ });
+
+ it('should render comment button as disabled', () => {
+ expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual('disabled');
+ });
+
+ it('should enable comment button if it has note', (done) => {
+ vm.note = 'Foo';
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual(null);
+ done();
+ });
+ });
+
+ it('should update buttons texts when it has note', (done) => {
+ vm.note = 'Foo';
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Comment & close issue');
+ expect(vm.$el.querySelector('.js-note-discard')).toBeDefined();
+ done();
+ });
+ });
+ });
+
+ describe('issue is confidential', () => {
+ it('shows information warning', (done) => {
+ store.dispatch('setIssueData', Object.assign(issueDataMock, { confidential: true }));
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.confidential-issue-warning')).toBeDefined();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('user is not logged in', () => {
+ beforeEach(() => {
+ store.dispatch('setUserData', null);
+ store.dispatch('setIssueData', loggedOutIssueData);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = mountComponent();
+ });
+
+ it('should render signed out widget', () => {
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply');
+ });
+
+ it('should not render submission form', () => {
+ expect(vm.$el.querySelector('textarea')).toEqual(null);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_discussion_spec.js b/spec/javascripts/notes/components/issue_discussion_spec.js
new file mode 100644
index 00000000000..05c6b57f93e
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_discussion_spec.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueDiscussion from '~/notes/components/issue_discussion.vue';
+import { issueDataMock, discussionMock, notesDataMock } from '../mock_data';
+
+describe('issue_discussion component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueDiscussion);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ propsData: {
+ note: discussionMock,
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render user avatar', () => {
+ expect(vm.$el.querySelector('.user-avatar-link')).toBeDefined();
+ });
+
+ it('should render discussion header', () => {
+ expect(vm.$el.querySelector('.discussion-header')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.notes li').length).toEqual(discussionMock.notes.length);
+ });
+
+ describe('actions', () => {
+ it('should render reply button', () => {
+ expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual('Reply...');
+ });
+
+ it('should toggle reply form', (done) => {
+ vm.$el.querySelector('.js-vue-discussion-reply').click();
+ Vue.nextTick(() => {
+ expect(vm.$refs.noteForm).toBeDefined();
+ expect(vm.isReplying).toEqual(true);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_actions_spec.js b/spec/javascripts/notes/components/issue_note_actions_spec.js
new file mode 100644
index 00000000000..7bcc061f167
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_actions_spec.js
@@ -0,0 +1,91 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueActions from '~/notes/components/issue_note_actions.vue';
+import { userDataMock } from '../mock_data';
+
+describe('issse_note_actions component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(issueActions);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user is logged in', () => {
+ let props;
+
+ beforeEach(() => {
+ props = {
+ accessLevel: 'Master',
+ authorId: 26,
+ canDelete: true,
+ canEdit: true,
+ canReportAsAbuse: true,
+ noteId: 539,
+ reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
+ };
+
+ store.dispatch('setUserData', userDataMock);
+
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ it('should render access level badge', () => {
+ expect(vm.$el.querySelector('.note-role').textContent.trim()).toEqual(props.accessLevel);
+ });
+
+ it('should render emoji link', () => {
+ expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
+ });
+
+ describe('actions dropdown', () => {
+ it('should be possible to edit the comment', () => {
+ expect(vm.$el.querySelector('.js-note-edit')).toBeDefined();
+ });
+
+ it('should be possible to report as abuse', () => {
+ expect(vm.$el.querySelector(`a[href="${props.reportAbusePath}"]`)).toBeDefined();
+ });
+
+ it('should be possible to delete comment', () => {
+ expect(vm.$el.querySelector('.js-note-delete')).toBeDefined();
+ });
+ });
+ });
+
+ describe('user is not logged in', () => {
+ let props;
+
+ beforeEach(() => {
+ store.dispatch('setUserData', {});
+ props = {
+ accessLevel: 'Master',
+ authorId: 26,
+ canDelete: false,
+ canEdit: false,
+ canReportAsAbuse: false,
+ noteId: 539,
+ reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
+ };
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ it('should not render emoji link', () => {
+ expect(vm.$el.querySelector('.js-add-award')).toEqual(null);
+ });
+
+ it('should not render actions dropdown', () => {
+ expect(vm.$el.querySelector('.more-actions')).toEqual(null);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_app_spec.js b/spec/javascripts/notes/components/issue_note_app_spec.js
new file mode 100644
index 00000000000..22e91c4c40f
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_app_spec.js
@@ -0,0 +1,255 @@
+import Vue from 'vue';
+import issueNotesApp from '~/notes/components/issue_notes_app.vue';
+import service from '~/notes/services/issue_notes_service';
+import * as mockData from '../mock_data';
+
+describe('issue_note_app', () => {
+ let mountComponent;
+ let vm;
+
+ const individualNoteInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(mockData.individualNoteServerResponse), {
+ status: 200,
+ }));
+ };
+
+ const discussionNoteInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(mockData.discussionNoteServerResponse), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ const IssueNotesApp = Vue.extend(issueNotesApp);
+
+ mountComponent = (data) => {
+ const props = data || {
+ issueData: mockData.issueDataMock,
+ notesData: mockData.notesDataMock,
+ userData: mockData.userDataMock,
+ };
+
+ return new IssueNotesApp({
+ propsData: props,
+ }).$mount();
+ };
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('set data', () => {
+ const responseInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify([]), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(responseInterceptor);
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, responseInterceptor);
+ });
+
+ it('should set notes data', () => {
+ expect(vm.$store.state.notesData).toEqual(mockData.notesDataMock);
+ });
+
+ it('should set issue data', () => {
+ expect(vm.$store.state.issueData).toEqual(mockData.issueDataMock);
+ });
+
+ it('should set user data', () => {
+ expect(vm.$store.state.userData).toEqual(mockData.userDataMock);
+ });
+
+ it('should fetch notes', () => {
+ expect(vm.$store.state.notes).toEqual([]);
+ });
+ });
+
+ describe('render', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(individualNoteInterceptor);
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
+ });
+
+ it('should render list of notes', (done) => {
+ const note = mockData.individualNoteServerResponse[0].notes[0];
+
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('.main-notes-list .note-header-author-name').textContent.trim(),
+ ).toEqual(note.author.name);
+
+ expect(vm.$el.querySelector('.main-notes-list .note-text').innerHTML).toEqual(note.note_html);
+ done();
+ }, 0);
+ });
+
+ it('should render form', () => {
+ expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+
+ it('should render form comment button as disabled', () => {
+ expect(
+ vm.$el.querySelector('.js-note-new-discussion').getAttribute('disabled'),
+ ).toEqual('disabled');
+ });
+ });
+
+ describe('while fetching data', () => {
+ beforeEach(() => {
+ vm = mountComponent();
+ });
+
+ it('should render loading icon', () => {
+ expect(vm.$el.querySelector('.js-loading')).toBeDefined();
+ });
+
+ it('should render form', () => {
+ expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+ });
+
+ describe('update note', () => {
+ describe('individual note', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(individualNoteInterceptor);
+ spyOn(service, 'updateNote').and.callFake(() => Promise.resolve());
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
+ });
+
+ it('renders edit form', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
+ done();
+ });
+ }, 0);
+ });
+
+ it('calls the service to update the note', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ expect(service.updateNote).toHaveBeenCalled();
+ done();
+ });
+ }, 0);
+ });
+ });
+
+ describe('dicussion note', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(discussionNoteInterceptor);
+ spyOn(service, 'updateNote').and.callFake(() => Promise.resolve());
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, discussionNoteInterceptor);
+ });
+
+ it('renders edit form', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
+ done();
+ });
+ }, 0);
+ });
+
+ it('updates the note and resets the edit form', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ expect(service.updateNote).toHaveBeenCalled();
+ done();
+ });
+ }, 0);
+ });
+ });
+ });
+
+ describe('new note form', () => {
+ beforeEach(() => {
+ vm = mountComponent();
+ });
+
+ it('should render markdown docs url', () => {
+ const { markdownDocsPath } = mockData.notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
+ });
+
+ it('should render quick action docs url', () => {
+ const { quickActionsDocsPath } = mockData.notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions');
+ });
+ });
+
+ describe('edit form', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(individualNoteInterceptor);
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
+ });
+
+ it('should render markdown docs url', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ const { markdownDocsPath } = mockData.notesDataMock;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector(`.edit-note a[href="${markdownDocsPath}"]`).textContent.trim(),
+ ).toEqual('Markdown is supported');
+ done();
+ });
+ }, 0);
+ });
+
+ it('should not render quick actions docs url', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ const { quickActionsDocsPath } = mockData.notesDataMock;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector(`.edit-note a[href="${quickActionsDocsPath}"]`),
+ ).toEqual(null);
+ done();
+ });
+ }, 0);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_attachment_spec.js b/spec/javascripts/notes/components/issue_note_attachment_spec.js
new file mode 100644
index 00000000000..8f33b874ad6
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_attachment_spec.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import issueNoteAttachment from '~/notes/components/issue_note_attachment.vue';
+
+describe('issue note attachment', () => {
+ it('should render properly', () => {
+ const props = {
+ attachment: {
+ filename: 'dk.png',
+ image: true,
+ url: '/dk.png',
+ },
+ };
+
+ const Component = Vue.extend(issueNoteAttachment);
+ const vm = new Component({
+ propsData: props,
+ }).$mount();
+
+ expect(vm.$el.classList.contains('note-attachment')).toBeTruthy();
+ expect(vm.$el.querySelector('img').src).toContain(props.attachment.url);
+ expect(vm.$el.querySelector('a').href).toContain(props.attachment.url);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_awards_list_spec.js b/spec/javascripts/notes/components/issue_note_awards_list_spec.js
new file mode 100644
index 00000000000..3b6c34f1494
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_awards_list_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import awardsNote from '~/notes/components/issue_note_awards_list.vue';
+import { issueDataMock, notesDataMock } from '../mock_data';
+
+describe('issue_note_awards_list component', () => {
+ let vm;
+ let awardsMock;
+
+ beforeEach(() => {
+ const Component = Vue.extend(awardsNote);
+
+ store.dispatch('setIssueData', issueDataMock);
+ 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,
+ toggleAwardPath: '/gitlab-org/gitlab-ce/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 awareded emoji', () => {
+ spyOn(vm, 'handleAward').and.callThrough();
+ vm.$el.querySelector('.js-awards-block button').click();
+
+ expect(vm.handleAward).toHaveBeenCalledWith('flag_tz');
+ });
+
+ it('should be possible to add new emoji', () => {
+ expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_body_spec.js b/spec/javascripts/notes/components/issue_note_body_spec.js
new file mode 100644
index 00000000000..81f07ed47cc
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_body_spec.js
@@ -0,0 +1,46 @@
+
+import Vue from 'vue';
+import store from '~/notes/stores';
+import noteBody from '~/notes/components/issue_note_body.vue';
+import { issueDataMock, notesDataMock, note } from '../mock_data';
+
+describe('issue_note_body component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(noteBody);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ propsData: {
+ note,
+ canEdit: true,
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render the note', () => {
+ expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
+ });
+
+ it('should be render form if user is editing', (done) => {
+ vm.isEditing = true;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined();
+ done();
+ });
+ });
+
+ it('should render awards list', () => {
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined();
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_edited_text_spec.js b/spec/javascripts/notes/components/issue_note_edited_text_spec.js
new file mode 100644
index 00000000000..6603241eb64
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_edited_text_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import issueNoteEditedText from '~/notes/components/issue_note_edited_text.vue';
+
+describe('issue_note_edited_text', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNoteEditedText);
+ props = {
+ actionText: 'Edited',
+ className: 'foo-bar',
+ editedAt: '2017-08-04T09:52:31.062Z',
+ editedBy: {
+ avatar_url: 'path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ };
+
+ vm = new Component({
+ propsData: props,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render block with provided className', () => {
+ expect(vm.$el.className).toEqual(props.className);
+ });
+
+ it('should render provided actionText', () => {
+ expect(vm.$el.textContent).toContain(props.actionText);
+ });
+
+ it('should render provided user information', () => {
+ const authorLink = vm.$el.querySelector('.js-vue-author');
+
+ expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
+ expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_form_spec.js b/spec/javascripts/notes/components/issue_note_form_spec.js
new file mode 100644
index 00000000000..a90dbcb72b5
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_form_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueNoteForm from '~/notes/components/issue_note_form.vue';
+import { issueDataMock, notesDataMock } from '../mock_data';
+import { keyboardDownEvent } from '../../issue_show/helpers';
+
+describe('issue_note_form component', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNoteForm);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ props = {
+ isEditing: false,
+ noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.',
+ noteId: 545,
+ };
+
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('conflicts editing', () => {
+ it('should show conflict message if note changes outside the component', (done) => {
+ vm.isEditing = true;
+ vm.noteBody = 'Foo';
+ const message = 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.js-conflict-edit-warning').textContent.replace(/\s+/g, ' ').trim(),
+ ).toEqual(message);
+ done();
+ });
+ });
+ });
+
+ describe('form', () => {
+ it('should render text area with placeholder', () => {
+ expect(
+ vm.$el.querySelector('textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+
+ it('should link to markdown docs', () => {
+ const { markdownDocsPath } = notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
+ });
+
+ describe('keyboard events', () => {
+ describe('up', () => {
+ it('should ender edit mode', () => {
+ spyOn(vm, 'editMyLastNote').and.callThrough();
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(38, true));
+
+ expect(vm.editMyLastNote).toHaveBeenCalled();
+ });
+ });
+
+ describe('enter', () => {
+ it('should submit note', () => {
+ spyOn(vm, 'handleUpdate').and.callThrough();
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(vm.handleUpdate).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('actions', () => {
+ it('should be possible to cancel', (done) => {
+ spyOn(vm, 'cancelHandler').and.callThrough();
+ vm.isEditing = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.note-edit-cancel').click();
+
+ Vue.nextTick(() => {
+ expect(vm.cancelHandler).toHaveBeenCalled();
+ done();
+ });
+ });
+ });
+
+ it('should be possible to update the note', (done) => {
+ vm.isEditing = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ Vue.nextTick(() => {
+ expect(vm.isSubmitting).toEqual(true);
+ done();
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_header_spec.js b/spec/javascripts/notes/components/issue_note_header_spec.js
new file mode 100644
index 00000000000..83ea18508ae
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_header_spec.js
@@ -0,0 +1,94 @@
+import Vue from 'vue';
+import issueNoteHeader from '~/notes/components/issue_note_header.vue';
+import store from '~/notes/stores';
+
+describe('issue_note_header component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(issueNoteHeader);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('individual note', () => {
+ beforeEach(() => {
+ vm = new Component({
+ store,
+ propsData: {
+ actionText: 'commented',
+ actionTextHtml: '',
+ author: {
+ avatar_url: null,
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ createdAt: '2017-08-02T10:51:58.559Z',
+ includeToggle: false,
+ noteId: 1394,
+ },
+ }).$mount();
+ });
+
+ it('should render user information', () => {
+ expect(
+ vm.$el.querySelector('.note-header-author-name').textContent.trim(),
+ ).toEqual('Root');
+ expect(
+ vm.$el.querySelector('.note-header-info a').getAttribute('href'),
+ ).toEqual('/root');
+ });
+
+ it('should render timestamp link', () => {
+ expect(vm.$el.querySelector('a[href="#note_1394"]')).toBeDefined();
+ });
+ });
+
+ describe('discussion', () => {
+ beforeEach(() => {
+ vm = new Component({
+ store,
+ propsData: {
+ actionText: 'started a discussion',
+ actionTextHtml: '',
+ author: {
+ avatar_url: null,
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ createdAt: '2017-08-02T10:51:58.559Z',
+ includeToggle: true,
+ noteId: 1395,
+ },
+ }).$mount();
+ });
+
+ it('should render toggle button', () => {
+ expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
+ });
+
+ it('should toggle the disucssion icon', (done) => {
+ expect(
+ vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
+ ).toEqual(true);
+
+ vm.$el.querySelector('.js-vue-toggle-button').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'),
+ ).toEqual(true);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js
new file mode 100644
index 00000000000..f20d9ce9268
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import issueNoteSignedOut from '~/notes/components/issue_note_signed_out_widget.vue';
+import store from '~/notes/stores';
+import { notesDataMock } from '../mock_data';
+
+describe('issue_note_signed_out_widget component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNoteSignedOut);
+ 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/issue_note_spec.js b/spec/javascripts/notes/components/issue_note_spec.js
new file mode 100644
index 00000000000..7ef85d5b4f0
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_spec.js
@@ -0,0 +1,44 @@
+
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueNote from '~/notes/components/issue_note.vue';
+import { issueDataMock, notesDataMock, note } from '../mock_data';
+
+describe('issue_note', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNote);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ propsData: {
+ note,
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render user information', () => {
+ expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(note.author.avatar_url);
+ });
+
+ it('should render note header content', () => {
+ expect(vm.$el.querySelector('.note-header .note-header-author-name').textContent.trim()).toEqual(note.author.name);
+ expect(vm.$el.querySelector('.note-header .note-headline-meta').textContent.trim()).toContain('commented');
+ });
+
+ it('should render note actions', () => {
+ expect(vm.$el.querySelector('.note-actions')).toBeDefined();
+ });
+
+ it('should render issue body', () => {
+ expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_placeholder_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_note_spec.js
new file mode 100644
index 00000000000..6e5275087f3
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_placeholder_note_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import issuePlaceholderNote from '~/notes/components/issue_placeholder_note.vue';
+import store from '~/notes/stores';
+import { userDataMock } from '../mock_data';
+
+describe('issue placeholder system note component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issuePlaceholderNote);
+ store.dispatch('setUserData', userDataMock);
+ vm = new Component({
+ store,
+ propsData: { note: { body: 'Foo' } },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user information', () => {
+ it('should render user avatar with link', () => {
+ expect(vm.$el.querySelector('.user-avatar-link').getAttribute('href')).toEqual(userDataMock.path);
+ expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(userDataMock.avatar_url);
+ });
+ });
+
+ describe('note content', () => {
+ it('should render note header information', () => {
+ expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual(userDataMock.path);
+ expect(vm.$el.querySelector('.note-header-info .note-headline-light').textContent.trim()).toEqual(`@${userDataMock.username}`);
+ });
+
+ it('should render note body', () => {
+ expect(vm.$el.querySelector('.note-text p').textContent.trim()).toEqual('Foo');
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js
new file mode 100644
index 00000000000..d508a49f710
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import placeholderSystemNote from '~/notes/components/issue_placeholder_system_note.vue';
+
+describe('issue placeholder system note component', () => {
+ let mountComponent;
+ beforeEach(() => {
+ const PlaceholderSystemNote = Vue.extend(placeholderSystemNote);
+
+ mountComponent = props => new PlaceholderSystemNote({
+ propsData: {
+ note: {
+ body: props,
+ },
+ },
+ }).$mount();
+ });
+
+ it('should render system note placeholder with plain text', () => {
+ const vm = mountComponent('This is a placeholder');
+
+ expect(vm.$el.tagName).toEqual('LI');
+ expect(vm.$el.querySelector('.timeline-content em').textContent.trim()).toEqual('This is a placeholder');
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_system_note_spec.js b/spec/javascripts/notes/components/issue_system_note_spec.js
new file mode 100644
index 00000000000..c317ce32716
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_system_note_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import issueSystemNote from '~/notes/components/issue_system_note.vue';
+import store from '~/notes/stores';
+
+describe('issue system note', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ props = {
+ note: {
+ id: 1424,
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'path',
+ path: '/root',
+ },
+ note_html: '<p dir="auto">closed</p>',
+ system_note_icon_name: 'icon_status_closed',
+ created_at: '2017-08-02T10:51:58.559Z',
+ },
+ };
+
+ store.dispatch('setTargetNoteHash', `note_${props.note.id}`);
+
+ const Component = Vue.extend(issueSystemNote);
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ it('should render a list item with correct id', () => {
+ expect(vm.$el.getAttribute('id')).toEqual(`note_${props.note.id}`);
+ });
+
+ it('should render target class is note is target note', () => {
+ expect(vm.$el.classList).toContain('target');
+ });
+
+ it('should render svg icon', () => {
+ expect(vm.$el.querySelector('.timeline-icon svg')).toBeDefined();
+ });
+
+ it('should render note header component', () => {
+ expect(
+ vm.$el.querySelector('.system-note-message').innerHTML,
+ ).toEqual(props.note.note_html);
+ });
+});
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
new file mode 100644
index 00000000000..89ba3a002b7
--- /dev/null
+++ b/spec/javascripts/notes/mock_data.js
@@ -0,0 +1,449 @@
+/* eslint-disable */
+export const notesDataMock = {
+ discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json',
+ lastFetchedAt: '1501862675',
+ markdownDocsPath: '/help/user/markdown',
+ newSessionPath: '/users/sign_in?redirect_to_referer=yes',
+ notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
+ quickActionsDocsPath: '/help/user/project/quick_actions',
+ registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
+};
+
+export const userDataMock = {
+ avatar_url: 'mock_path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+};
+
+export const issueDataMock = {
+ assignees: [],
+ author_id: 1,
+ branch_name: null,
+ confidential: false,
+ create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue',
+ created_at: '2017-02-07T10:11:18.395Z',
+ current_user: {
+ can_create_note: true,
+ can_update: true,
+ },
+ deleted_at: null,
+ description: '',
+ due_date: null,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ id: 98,
+ iid: 26,
+ labels: [],
+ lock_version: null,
+ milestone: null,
+ milestone_id: null,
+ moved_to_id: null,
+ preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue',
+ project_id: 2,
+ state: 'opened',
+ time_estimate: 0,
+ title: '14',
+ total_time_spent: 0,
+ updated_at: '2017-08-04T09:53:01.226Z',
+ updated_by_id: 1,
+ web_url: '/gitlab-org/gitlab-ce/issues/26',
+};
+
+export const lastFetchedAt = '1501862675';
+
+export const individualNote = {
+ expanded: true,
+ id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ individual_note: true,
+ notes: [{
+ id: 1390,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'test',
+ path: '/root',
+ },
+ created_at: '2017-08-01T17: 09: 33.762Z',
+ updated_at: '2017-08-01T17: 09: 33.762Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'sdfdsaf',
+ note_html: '<p dir=\'auto\'>sdfdsaf</p>',
+ current_user: { can_edit: true },
+ discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ emoji_awardable: true,
+ award_emoji: [
+ { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } },
+ { name: 'art', user: { id: 1, name: 'Root', username: 'root' } },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1390',
+ }],
+ reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+};
+
+export const note = {
+ "id": 546,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "path": "/root"
+ },
+ "created_at": "2017-08-10T15:24:03.087Z",
+ "updated_at": "2017-08-10T15:24:03.087Z",
+ "system": false,
+ "noteable_id": 67,
+ "noteable_type": "Issue",
+ "noteable_iid": 7,
+ "type": null,
+ "human_access": "Owner",
+ "note": "Vel id placeat reprehenderit sit numquam.",
+ "note_html": "<p dir=\"auto\">Vel id placeat reprehenderit sit numquam.</p>",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "d3842a451b7f3d9a5dfce329515127b2d29a4cd0",
+ "emoji_awardable": true,
+ "award_emoji": [{
+ "name": "baseball",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root"
+ }
+ }, {
+ "name": "bath_tone3",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root"
+ }
+ }],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/546"
+ }
+
+export const discussionMock = {
+ id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ expanded: true,
+ notes: [{
+ id: 1395,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:51:58.559Z',
+ updated_at: '2017-08-02T10:51:58.559Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'THIS IS A DICUSSSION!',
+ note_html: '<p dir=\'auto\'>THIS IS A DICUSSSION!</p>',
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1395',
+ }, {
+ id: 1396,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:56:50.980Z',
+ updated_at: '2017-08-03T14:19:35.691Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'sadfasdsdgdsf',
+ note_html: '<p dir=\'auto\'>sadfasdsdgdsf</p>',
+ last_edited_at: '2017-08-03T14:19:35.691Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1396',
+ }, {
+ id: 1437,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-03T18:11:18.780Z',
+ updated_at: '2017-08-04T09:52:31.062Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'adsfasf Should disappear',
+ note_html: '<p dir=\'auto\'>adsfasf Should disappear</p>',
+ last_edited_at: '2017-08-04T09:52:31.062Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1437',
+ }],
+ individual_note: false,
+};
+
+export const loggedOutIssueData = {
+ "id": 98,
+ "iid": 26,
+ "author_id": 1,
+ "description": "",
+ "lock_version": 1,
+ "milestone_id": null,
+ "state": "opened",
+ "title": "asdsa",
+ "updated_by_id": 1,
+ "created_at": "2017-02-07T10:11:18.395Z",
+ "updated_at": "2017-08-08T10:22:51.564Z",
+ "deleted_at": null,
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "milestone": null,
+ "labels": [],
+ "branch_name": null,
+ "confidential": false,
+ "assignees": [{
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "web_url": "http://localhost:3000/root"
+ }],
+ "due_date": null,
+ "moved_to_id": null,
+ "project_id": 2,
+ "web_url": "/gitlab-org/gitlab-ce/issues/26",
+ "current_user": {
+ "can_create_note": false,
+ "can_update": false
+ },
+ "create_note_path": "/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue",
+ "preview_note_path": "/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue"
+}
+
+export const individualNoteServerResponse = [{
+ "id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
+ "reply_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
+ "expanded": true,
+ "notes": [{
+ "id": 1390,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "path": "/root"
+ },
+ "created_at": "2017-08-01T17:09:33.762Z",
+ "updated_at": "2017-08-01T17:09:33.762Z",
+ "system": false,
+ "noteable_id": 98,
+ "noteable_type": "Issue",
+ "type": null,
+ "human_access": "Owner",
+ "note": "sdfdsaf",
+ "note_html": "\u003cp dir=\"auto\"\u003esdfdsaf\u003c/p\u003e",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
+ "emoji_awardable": true,
+ "award_emoji": [{
+ "name": "baseball",
+ "user": {
+ "id": 1,
+ "name": "Root",
+ "username": "root"
+ }
+ }, {
+ "name": "art",
+ "user": {
+ "id": 1,
+ "name": "Root",
+ "username": "root"
+ }
+ }],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/1390"
+ }],
+ "individual_note": true
+ }, {
+ "id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
+ "reply_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
+ "expanded": true,
+ "notes": [{
+ "id": 1391,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "path": "/root"
+ },
+ "created_at": "2017-08-02T10:51:38.685Z",
+ "updated_at": "2017-08-02T10:51:38.685Z",
+ "system": false,
+ "noteable_id": 98,
+ "noteable_type": "Issue",
+ "type": null,
+ "human_access": "Owner",
+ "note": "New note!",
+ "note_html": "\u003cp dir=\"auto\"\u003eNew note!\u003c/p\u003e",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
+ "emoji_awardable": true,
+ "award_emoji": [],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/1391"
+ }],
+ "individual_note": true
+}];
+
+export const discussionNoteServerResponse = [{
+ "id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
+ "reply_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
+ "expanded": true,
+ "notes": [{
+ "id": 1471,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "path": "/root"
+ },
+ "created_at": "2017-08-08T16:53:00.666Z",
+ "updated_at": "2017-08-08T16:53:00.666Z",
+ "system": false,
+ "noteable_id": 124,
+ "noteable_type": "Issue",
+ "noteable_iid": 29,
+ "type": "DiscussionNote",
+ "human_access": "Owner",
+ "note": "Adding a comment",
+ "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
+ "emoji_awardable": true,
+ "award_emoji": [],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/1471"
+ }],
+ "individual_note": false
+}];
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
new file mode 100644
index 00000000000..72d362acb2f
--- /dev/null
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -0,0 +1,62 @@
+
+import * as actions from '~/notes/stores/actions';
+import testAction from './helpers';
+import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
+
+describe('Actions Notes Store', () => {
+ describe('setNotesData', () => {
+ it('should set received notes data', (done) => {
+ testAction(actions.setNotesData, null, { notesData: {} }, [
+ { type: 'SET_NOTES_DATA', payload: notesDataMock },
+ ], done);
+ });
+ });
+
+ describe('setIssueData', () => {
+ it('should set received issue data', (done) => {
+ testAction(actions.setIssueData, null, { issueData: {} }, [
+ { type: 'SET_ISSUE_DATA', payload: issueDataMock },
+ ], done);
+ });
+ });
+
+ describe('setUserData', () => {
+ it('should set received user data', (done) => {
+ testAction(actions.setUserData, null, { userData: {} }, [
+ { type: 'SET_USER_DATA', payload: userDataMock },
+ ], done);
+ });
+ });
+
+ describe('setLastFetchedAt', () => {
+ it('should set received timestamp', (done) => {
+ testAction(actions.setLastFetchedAt, null, { lastFetchedAt: {} }, [
+ { type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' },
+ ], done);
+ });
+ });
+
+ describe('setInitialNotes', () => {
+ it('should set initial notes', (done) => {
+ testAction(actions.setInitialNotes, null, { notes: [] }, [
+ { type: 'SET_INITIAL_NOTES', payload: [individualNote] },
+ ], done);
+ });
+ });
+
+ describe('setTargetNoteHash', () => {
+ it('should set target note hash', (done) => {
+ testAction(actions.setTargetNoteHash, null, { notes: [] }, [
+ { type: 'SET_TARGET_NOTE_HASH', payload: 'hash' },
+ ], done);
+ });
+ });
+
+ describe('toggleDiscussion', () => {
+ it('should toggle discussion', (done) => {
+ testAction(actions.toggleDiscussion, null, { notes: [discussionMock] }, [
+ { type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } },
+ ], done);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js
new file mode 100644
index 00000000000..48ee1bf9a52
--- /dev/null
+++ b/spec/javascripts/notes/stores/getters_spec.js
@@ -0,0 +1,58 @@
+import * as getters from '~/notes/stores/getters';
+import { notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
+
+describe('Getters Notes Store', () => {
+ let state;
+ beforeEach(() => {
+ state = {
+ notes: [individualNote],
+ targetNoteHash: 'hash',
+ lastFetchedAt: 'timestamp',
+
+ notesData: notesDataMock,
+ userData: userDataMock,
+ issueData: issueDataMock,
+ };
+ });
+ describe('notes', () => {
+ it('should return all notes in the store', () => {
+ expect(getters.notes(state)).toEqual([individualNote]);
+ });
+ });
+
+ describe('targetNoteHash', () => {
+ it('should return `targetNoteHash`', () => {
+ expect(getters.targetNoteHash(state)).toEqual('hash');
+ });
+ });
+
+ describe('getNotesData', () => {
+ it('should return all data in `notesData`', () => {
+ expect(getters.getNotesData(state)).toEqual(notesDataMock);
+ });
+ });
+
+ describe('getIssueData', () => {
+ it('should return all data in `issueData`', () => {
+ expect(getters.getIssueData(state)).toEqual(issueDataMock);
+ });
+ });
+
+ describe('getUserData', () => {
+ it('should return all data in `userData`', () => {
+ expect(getters.getUserData(state)).toEqual(userDataMock);
+ });
+ });
+
+ describe('notesById', () => {
+ it('should return the note for the given id', () => {
+ expect(getters.notesById(state)).toEqual({ 1390: individualNote.notes[0] });
+ });
+ });
+
+ describe('getCurrentUserLastNote', () => {
+ it('should return the last note of the current user', () => {
+ expect(getters.getCurrentUserLastNote(state)).toEqual(individualNote.notes[0]);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/stores/helpers.js b/spec/javascripts/notes/stores/helpers.js
new file mode 100644
index 00000000000..2d386fe1da5
--- /dev/null
+++ b/spec/javascripts/notes/stores/helpers.js
@@ -0,0 +1,37 @@
+/* eslint-disable */
+
+/**
+ * helper for testing action with expected mutations
+ * https://vuex.vuejs.org/en/testing.html
+ */
+export default (action, payload, state, expectedMutations, done) => {
+ let count = 0;
+
+ // mock commit
+ const commit = (type, payload) => {
+ const mutation = expectedMutations[count];
+
+ try {
+ expect(mutation.type).to.equal(type);
+ if (payload) {
+ expect(mutation.payload).to.deep.equal(payload);
+ }
+ } catch (error) {
+ done(error);
+ }
+
+ count++;
+ if (count >= expectedMutations.length) {
+ done();
+ }
+ };
+
+ // call the action with mocked store and arguments
+ action({ commit, state }, payload);
+
+ // check if no mutations should have been dispatched
+ if (expectedMutations.length === 0) {
+ expect(count).to.equal(0);
+ done();
+ }
+};
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
new file mode 100644
index 00000000000..a38f29c1e39
--- /dev/null
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -0,0 +1,207 @@
+import mutations from '~/notes/stores/mutations';
+import { note, discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
+
+describe('Mutation Notes Store', () => {
+ describe('ADD_NEW_NOTE', () => {
+ it('should add a new note to an array of notes', () => {
+ const state = { notes: [] };
+ mutations.ADD_NEW_NOTE(state, note);
+
+ expect(state).toEqual({
+ notes: [{
+ expanded: true,
+ id: note.discussion_id,
+ individual_note: true,
+ notes: [note],
+ reply_id: note.discussion_id,
+ }],
+ });
+ });
+ });
+
+ describe('ADD_NEW_REPLY_TO_DISCUSSION', () => {
+ it('should add a reply to a specific discussion', () => {
+ const state = { notes: [discussionMock] };
+ const newReply = Object.assign({}, note, { discussion_id: discussionMock.id });
+ mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply);
+
+ expect(state.notes[0].notes.length).toEqual(4);
+ });
+ });
+
+ describe('DELETE_NOTE', () => {
+ it('should delete a note ', () => {
+ const state = { notes: [discussionMock] };
+ const toDelete = discussionMock.notes[0];
+ const lengthBefore = discussionMock.notes.length;
+
+ mutations.DELETE_NOTE(state, toDelete);
+
+ expect(state.notes[0].notes.length).toEqual(lengthBefore - 1);
+ });
+ });
+
+ describe('REMOVE_PLACEHOLDER_NOTES', () => {
+ it('should remove all placeholder notes in indivudal notes and discussion', () => {
+ const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true });
+ const state = { notes: [placeholderNote] };
+ mutations.REMOVE_PLACEHOLDER_NOTES(state);
+
+ expect(state.notes).toEqual([]);
+ });
+ });
+
+ describe('SET_NOTES_DATA', () => {
+ it('should set an object with notesData', () => {
+ const state = {
+ notesData: {},
+ };
+
+ mutations.SET_NOTES_DATA(state, notesDataMock);
+ expect(state.notesData).toEqual(notesDataMock);
+ });
+ });
+
+ describe('SET_ISSUE_DATA', () => {
+ it('should set the issue data', () => {
+ const state = {
+ issueData: {},
+ };
+
+ mutations.SET_ISSUE_DATA(state, issueDataMock);
+ expect(state.issueData).toEqual(issueDataMock);
+ });
+ });
+
+ describe('SET_USER_DATA', () => {
+ it('should set the user data', () => {
+ const state = {
+ userData: {},
+ };
+
+ mutations.SET_USER_DATA(state, userDataMock);
+ expect(state.userData).toEqual(userDataMock);
+ });
+ });
+
+ describe('SET_INITIAL_NOTES', () => {
+ it('should set the initial notes received', () => {
+ const state = {
+ notes: [],
+ };
+
+ mutations.SET_INITIAL_NOTES(state, [note]);
+ expect(state.notes).toEqual([note]);
+ });
+ });
+
+ describe('SET_LAST_FETCHED_AT', () => {
+ it('should set timestamp', () => {
+ const state = {
+ lastFetchedAt: [],
+ };
+
+ mutations.SET_LAST_FETCHED_AT(state, 'timestamp');
+ expect(state.lastFetchedAt).toEqual('timestamp');
+ });
+ });
+
+ describe('SET_TARGET_NOTE_HASH', () => {
+ it('should set the note hash', () => {
+ const state = {
+ targetNoteHash: [],
+ };
+
+ mutations.SET_TARGET_NOTE_HASH(state, 'hash');
+ expect(state.targetNoteHash).toEqual('hash');
+ });
+ });
+
+ describe('SHOW_PLACEHOLDER_NOTE', () => {
+ it('should set a placeholder note', () => {
+ const state = {
+ notes: [],
+ };
+ mutations.SHOW_PLACEHOLDER_NOTE(state, note);
+ expect(state.notes[0].isPlaceholderNote).toEqual(true);
+ });
+ });
+
+ describe('TOGGLE_AWARD', () => {
+ it('should add award if user has not reacted yet', () => {
+ const state = {
+ notes: [note],
+ userData: userDataMock,
+ };
+
+ const data = {
+ note,
+ awardName: 'cartwheel',
+ };
+
+ mutations.TOGGLE_AWARD(state, data);
+ const lastIndex = state.notes[0].award_emoji.length - 1;
+
+ expect(state.notes[0].award_emoji[lastIndex]).toEqual({
+ name: 'cartwheel',
+ user: { id: userDataMock.id, name: userDataMock.name, username: userDataMock.username },
+ });
+ });
+
+ it('should remove award if user already reacted', () => {
+ const state = {
+ notes: [note],
+ userData: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+ };
+
+ const data = {
+ note,
+ awardName: 'bath_tone3',
+ };
+ mutations.TOGGLE_AWARD(state, data);
+ expect(state.notes[0].award_emoji.length).toEqual(2);
+ });
+ });
+
+ describe('TOGGLE_DISCUSSION', () => {
+ it('should open a closed discussion', () => {
+ const discussion = Object.assign({}, discussionMock, { expanded: false });
+
+ const state = {
+ notes: [discussion],
+ };
+
+ mutations.TOGGLE_DISCUSSION(state, { discussionId: discussion.id });
+
+ expect(state.notes[0].expanded).toEqual(true);
+ });
+
+ it('should close a opened discussion', () => {
+ const state = {
+ notes: [discussionMock],
+ };
+
+ mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id });
+
+ expect(state.notes[0].expanded).toEqual(false);
+ });
+ });
+
+ describe('UPDATE_NOTE', () => {
+ it('should update a note', () => {
+ const state = {
+ notes: [individualNote],
+ };
+
+ const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' });
+
+ mutations.UPDATE_NOTE(state, updated);
+
+ expect(state.notes[0].notes[0].note).toEqual('Foo');
+ });
+ });
+});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 2c096ed08a8..8c5ad8914b0 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -32,14 +32,14 @@ import '~/notes';
describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert';
- var commentsTemplate = 'issues/issue_with_comment.html.raw';
+ var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate);
beforeEach(function () {
loadFixtures(commentsTemplate);
gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads';
- $('body').data('page', 'projects:issues:show');
+ $('body').data('page', 'projects:merge_requets:show');
});
describe('task lists', function() {
@@ -53,17 +53,19 @@ import '~/notes';
it('modifies the Markdown field', function() {
const changeEvent = document.createEvent('HTMLEvents');
changeEvent.initEvent('change', true, true);
- $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent);
- expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
+ $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent);
+
+ expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item');
});
it('submits an ajax request on tasklist:changed', function() {
spyOn(jQuery, 'ajax').and.callFake(function(req) {
expect(req.type).toBe('PATCH');
- expect(req.url).toBe('http://test.host/frontend-fixtures/issues-project/notes/1');
+ expect(req.url).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1.json');
return expect(req.data.note).not.toBe(null);
});
- $('.js-task-list-field').trigger('tasklist:changed');
+
+ $('.js-task-list-field.js-note-text').trigger('tasklist:changed');
});
});
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index 3515dfbc60b..a912e150e9b 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,78 +1,74 @@
-/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */
import '~/copy_as_gfm';
import '~/shortcuts_issuable';
-(function() {
- describe('ShortcutsIssuable', function() {
- var fixtureName = 'issues/open-issue.html.raw';
- preloadFixtures(fixtureName);
- beforeEach(function() {
- loadFixtures(fixtureName);
- document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
- this.shortcut = new ShortcutsIssuable();
- });
- describe('replyWithSelectedText', function() {
- var stubSelection;
- // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
- stubSelection = function(html) {
- window.gl.utils.getSelectedFragment = function() {
- var node = document.createElement('div');
- node.innerHTML = html;
- return node;
- };
+describe('ShortcutsIssuable', () => {
+ const fixtureName = 'merge_requests/diff_comment.html.raw';
+ preloadFixtures(fixtureName);
+ beforeEach(() => {
+ loadFixtures(fixtureName);
+ document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
+ this.shortcut = new ShortcutsIssuable(true);
+ });
+ describe('replyWithSelectedText', () => {
+ // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
+ const stubSelection = (html) => {
+ window.gl.utils.getSelectedFragment = () => {
+ const node = document.createElement('div');
+ node.innerHTML = html;
+ return node;
};
- beforeEach(function() {
- this.selector = 'form.js-main-target-form textarea#note_note';
+ };
+ beforeEach(() => {
+ this.selector = '.js-main-target-form #note_note';
+ });
+ describe('with empty selection', () => {
+ it('does not return an error', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('');
});
- describe('with empty selection', function() {
- it('does not return an error', function() {
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe('');
- });
- it('triggers `focus`', function() {
- this.shortcut.replyWithSelectedText();
- expect(document.activeElement).toBe(document.querySelector(this.selector));
- });
+ it('triggers `focus`', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect(document.activeElement).toBe(document.querySelector(this.selector));
});
- describe('with any selection', function() {
- beforeEach(function() {
- stubSelection('<p>Selected text.</p>');
- });
- it('leaves existing input intact', function() {
- $(this.selector).val('This text was already here.');
- expect($(this.selector).val()).toBe('This text was already here.');
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("This text was already here.\n\n> Selected text.\n\n");
- });
- it('triggers `input`', function() {
- var triggered = false;
- $(this.selector).on('input', function() {
- triggered = true;
- });
- this.shortcut.replyWithSelectedText();
- expect(triggered).toBe(true);
- });
- it('triggers `focus`', function() {
- this.shortcut.replyWithSelectedText();
- expect(document.activeElement).toBe(document.querySelector(this.selector));
- });
+ });
+ describe('with any selection', () => {
+ beforeEach(() => {
+ stubSelection('<p>Selected text.</p>');
});
- describe('with a one-line selection', function() {
- it('quotes the selection', function() {
- stubSelection('<p>This text has been selected.</p>');
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("> This text has been selected.\n\n");
- });
+ it('leaves existing input intact', () => {
+ $(this.selector).val('This text was already here.');
+ expect($(this.selector).val()).toBe('This text was already here.');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('This text was already here.\n\n> Selected text.\n\n');
});
- describe('with a multi-line selection', function() {
- it('quotes the selected lines as a group', function() {
- stubSelection("<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>");
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n");
+ it('triggers `input`', () => {
+ let triggered = false;
+ $(this.selector).on('input', () => {
+ triggered = true;
});
+ this.shortcut.replyWithSelectedText(true);
+ expect(triggered).toBe(true);
+ });
+ it('triggers `focus`', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect(document.activeElement).toBe(document.querySelector(this.selector));
+ });
+ });
+ describe('with a one-line selection', () => {
+ it('quotes the selection', () => {
+ stubSelection('<p>This text has been selected.</p>');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('> This text has been selected.\n\n');
+ });
+ });
+ describe('with a multi-line selection', () => {
+ it('quotes the selected lines as a group', () => {
+ stubSelection('<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n');
});
});
});
-}).call(window);
+});
diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js
index 9b8373df29e..53e4c68beb3 100644
--- a/spec/javascripts/shortcuts_spec.js
+++ b/spec/javascripts/shortcuts_spec.js
@@ -1,6 +1,6 @@
/* global Shortcuts */
describe('Shortcuts', () => {
- const fixtureName = 'issues/issue_with_comment.html.raw';
+ const fixtureName = 'merge_requests/diff_comment.html.raw';
const createEvent = (type, target) => $.Event(type, {
target,
});
diff --git a/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js
new file mode 100644
index 00000000000..6df08f3ebe7
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import confidentialIssue from '~/vue_shared/components/issue/confidential_issue_warning.vue';
+
+describe('Confidential Issue Warning Component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(confidentialIssue);
+ vm = new Component().$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render confidential issue warning information', () => {
+ expect(vm.$el.querySelector('i').className).toEqual('fa fa-eye-slash');
+ expect(vm.$el.querySelector('span').textContent.trim()).toEqual('This is a confidential issue. Your comment will not be visible to the public.');
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js
index 291e19c9f3c..60a5c2ae74e 100644
--- a/spec/javascripts/vue_shared/components/markdown/field_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js
@@ -16,8 +16,8 @@ describe('Markdown field component', () => {
},
template: `
<field-component
- marodown-preview-url="/preview"
- markdown-docs="/docs"
+ markdown-preview-path="/preview"
+ markdown-docs-path="/docs"
>
<textarea
slot="textarea"
@@ -92,6 +92,7 @@ describe('Markdown field component', () => {
it('renders GFM with jQuery', (done) => {
spyOn($.fn, 'renderGFM');
+
previewLink.click();
setTimeout(() => {
@@ -100,7 +101,7 @@ describe('Markdown field component', () => {
).toHaveBeenCalled();
done();
- });
+ }, 0);
});
});
diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js
index a225b04c47e..bd18f79cea7 100644
--- a/spec/javascripts/zen_mode_spec.js
+++ b/spec/javascripts/zen_mode_spec.js
@@ -8,7 +8,7 @@ import ZenMode from '~/zen_mode';
var enterZen, escapeKeydown, exitZen;
describe('ZenMode', function() {
- var fixtureName = 'issues/open-issue.html.raw';
+ var fixtureName = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(fixtureName);
beforeEach(function() {
loadFixtures(fixtureName);
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index 4a498e79c87..f685bb83d0d 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -279,16 +279,6 @@ describe Gitlab::Auth do
gl_auth.find_with_user_password('ldap_user', 'password')
end
end
-
- context "with sign-in disabled" do
- before do
- stub_application_setting(password_authentication_enabled: false)
- end
-
- it "does not find user by valid login/password" do
- expect(gl_auth.find_with_user_password(username, password)).to be_nil
- end
- end
end
private
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 295a979da76..458627ee4de 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -155,6 +155,44 @@ describe Gitlab::GitAccess do
end
end
+ shared_examples '#check with a key that is not valid' do
+ before do
+ project.add_master(user)
+ end
+
+ context 'key is too small' do
+ before do
+ stub_application_setting(rsa_key_restriction: 4096)
+ end
+
+ it 'does not allow keys which are too small', aggregate_failures: true do
+ expect(actor).not_to be_valid
+ expect { pull_access_check }.to raise_unauthorized('Your SSH key must be at least 4096 bits.')
+ expect { push_access_check }.to raise_unauthorized('Your SSH key must be at least 4096 bits.')
+ end
+ end
+
+ context 'key type is not allowed' do
+ before do
+ stub_application_setting(rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE)
+ end
+
+ it 'does not allow keys which are too small', aggregate_failures: true do
+ expect(actor).not_to be_valid
+ expect { pull_access_check }.to raise_unauthorized(/Your SSH key type is forbidden/)
+ expect { push_access_check }.to raise_unauthorized(/Your SSH key type is forbidden/)
+ end
+ end
+ end
+
+ it_behaves_like '#check with a key that is not valid' do
+ let(:actor) { build(:rsa_key_2048, user: user) }
+ end
+
+ it_behaves_like '#check with a key that is not valid' do
+ let(:actor) { build(:rsa_deploy_key_2048, user: user) }
+ end
+
describe '#check_project_moved!' do
before do
project.add_master(user)
diff --git a/spec/lib/gitlab/i18n/metadata_entry_spec.rb b/spec/lib/gitlab/i18n/metadata_entry_spec.rb
new file mode 100644
index 00000000000..ab71d6454a9
--- /dev/null
+++ b/spec/lib/gitlab/i18n/metadata_entry_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Gitlab::I18n::MetadataEntry do
+ describe '#expected_plurals' do
+ it 'returns the number of plurals' do
+ data = {
+ msgid: "",
+ msgstr: [
+ "",
+ "Project-Id-Version: gitlab 1.0.0\\n",
+ "Report-Msgid-Bugs-To: \\n",
+ "PO-Revision-Date: 2017-07-13 12:10-0500\\n",
+ "Language-Team: Spanish\\n",
+ "Language: es\\n",
+ "MIME-Version: 1.0\\n",
+ "Content-Type: text/plain; charset=UTF-8\\n",
+ "Content-Transfer-Encoding: 8bit\\n",
+ "Plural-Forms: nplurals=2; plural=n != 1;\\n",
+ "Last-Translator: Bob Van Landuyt <bob@gitlab.com>\\n",
+ "X-Generator: Poedit 2.0.2\\n"
+ ]
+ }
+ entry = described_class.new(data)
+
+ expect(entry.expected_plurals).to eq(2)
+ end
+
+ it 'returns 0 for the POT-metadata' do
+ data = {
+ msgid: "",
+ msgstr: [
+ "",
+ "Project-Id-Version: gitlab 1.0.0\\n",
+ "Report-Msgid-Bugs-To: \\n",
+ "PO-Revision-Date: 2017-07-13 12:10-0500\\n",
+ "Language-Team: Spanish\\n",
+ "Language: es\\n",
+ "MIME-Version: 1.0\\n",
+ "Content-Type: text/plain; charset=UTF-8\\n",
+ "Content-Transfer-Encoding: 8bit\\n",
+ "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n",
+ "Last-Translator: Bob Van Landuyt <bob@gitlab.com>\\n",
+ "X-Generator: Poedit 2.0.2\\n"
+ ]
+ }
+ entry = described_class.new(data)
+
+ expect(entry.expected_plurals).to eq(0)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb
new file mode 100644
index 00000000000..cd5c2b99751
--- /dev/null
+++ b/spec/lib/gitlab/i18n/po_linter_spec.rb
@@ -0,0 +1,337 @@
+require 'spec_helper'
+
+describe Gitlab::I18n::PoLinter do
+ let(:linter) { described_class.new(po_path) }
+ let(:po_path) { 'spec/fixtures/valid.po' }
+
+ describe '#errors' do
+ it 'only calls validation once' do
+ expect(linter).to receive(:validate_po).once.and_call_original
+
+ 2.times { linter.errors }
+ end
+ end
+
+ describe '#validate_po' do
+ subject(:errors) { linter.validate_po }
+
+ context 'for a fuzzy message' do
+ let(:po_path) { 'spec/fixtures/fuzzy.po' }
+
+ it 'has an error' do
+ is_expected.to include('PipelineSchedules|Remove variable row' => ['is marked fuzzy'])
+ end
+ end
+
+ context 'for a translations with newlines' do
+ let(:po_path) { 'spec/fixtures/newlines.po' }
+
+ it 'has an error for a normal string' do
+ message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?"
+ expected_message = "is defined over multiple lines, this breaks some tooling."
+
+ expect(errors[message_id]).to include(expected_message)
+ end
+
+ it 'has an error when a translation is defined over multiple lines' do
+ message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?"
+ expected_message = "has translations defined over multiple lines, this breaks some tooling."
+
+ expect(errors[message_id]).to include(expected_message)
+ end
+
+ it 'raises an error when a plural translation is defined over multiple lines' do
+ message_id = 'With plural'
+ expected_message = "has translations defined over multiple lines, this breaks some tooling."
+
+ expect(errors[message_id]).to include(expected_message)
+ end
+
+ it 'raises an error when the plural id is defined over multiple lines' do
+ message_id = 'multiline plural id'
+ expected_message = "plural is defined over multiple lines, this breaks some tooling."
+
+ expect(errors[message_id]).to include(expected_message)
+ end
+ end
+
+ context 'with an invalid po' do
+ let(:po_path) { 'spec/fixtures/invalid.po' }
+
+ it 'returns the error' do
+ is_expected.to include('PO-syntax errors' => a_kind_of(Array))
+ end
+
+ it 'does not validate entries' do
+ expect(linter).not_to receive(:validate_entries)
+
+ linter.validate_po
+ end
+ end
+
+ context 'with missing metadata' do
+ let(:po_path) { 'spec/fixtures/missing_metadata.po' }
+
+ it 'returns the an error' do
+ is_expected.to include('PO-syntax errors' => a_kind_of(Array))
+ end
+ end
+
+ context 'with a valid po' do
+ it 'parses the file' do
+ expect(linter).to receive(:parse_po).and_call_original
+
+ linter.validate_po
+ end
+
+ it 'validates the entries' do
+ expect(linter).to receive(:validate_entries).and_call_original
+
+ linter.validate_po
+ end
+
+ it 'has no errors' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'with missing plurals' do
+ let(:po_path) { 'spec/fixtures/missing_plurals.po' }
+
+ it 'has errors' do
+ is_expected.not_to be_empty
+ end
+ end
+
+ context 'with multiple plurals' do
+ let(:po_path) { 'spec/fixtures/multiple_plurals.po' }
+
+ it 'has errors' do
+ is_expected.not_to be_empty
+ end
+ end
+
+ context 'with unescaped chars' do
+ let(:po_path) { 'spec/fixtures/unescaped_chars.po' }
+
+ it 'contains an error' do
+ message_id = 'You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?'
+ expected_error = 'translation contains unescaped `%`, escape it using `%%`'
+
+ expect(errors[message_id]).to include(expected_error)
+ end
+ end
+ end
+
+ describe '#parse_po' do
+ context 'with a valid po' do
+ it 'fills in the entries' do
+ linter.parse_po
+
+ expect(linter.translation_entries).not_to be_empty
+ expect(linter.metadata_entry).to be_kind_of(Gitlab::I18n::MetadataEntry)
+ end
+
+ it 'does not have errors' do
+ expect(linter.parse_po).to be_nil
+ end
+ end
+
+ context 'with an invalid po' do
+ let(:po_path) { 'spec/fixtures/invalid.po' }
+
+ it 'contains an error' do
+ expect(linter.parse_po).not_to be_nil
+ end
+
+ it 'sets the entries to an empty array' do
+ linter.parse_po
+
+ expect(linter.translation_entries).to eq([])
+ end
+ end
+ end
+
+ describe '#validate_entries' do
+ it 'keeps track of errors for entries' do
+ fake_invalid_entry = Gitlab::I18n::TranslationEntry.new(
+ { msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }, 2
+ )
+ allow(linter).to receive(:translation_entries) { [fake_invalid_entry] }
+
+ expect(linter).to receive(:validate_entry)
+ .with(fake_invalid_entry)
+ .and_call_original
+
+ expect(linter.validate_entries).to include("Hello %{world}" => an_instance_of(Array))
+ end
+ end
+
+ describe '#validate_entry' do
+ it 'validates the flags, variable usage, newlines, and unescaped chars' do
+ fake_entry = double
+
+ expect(linter).to receive(:validate_flags).with([], fake_entry)
+ expect(linter).to receive(:validate_variables).with([], fake_entry)
+ expect(linter).to receive(:validate_newlines).with([], fake_entry)
+ expect(linter).to receive(:validate_number_of_plurals).with([], fake_entry)
+ expect(linter).to receive(:validate_unescaped_chars).with([], fake_entry)
+
+ linter.validate_entry(fake_entry)
+ end
+ end
+
+ describe '#validate_number_of_plurals' do
+ it 'validates when there are an incorrect number of translations' do
+ fake_metadata = double
+ allow(fake_metadata).to receive(:expected_plurals).and_return(2)
+ allow(linter).to receive(:metadata_entry).and_return(fake_metadata)
+
+ fake_entry = Gitlab::I18n::TranslationEntry.new(
+ { msgid: 'the singular', msgid_plural: 'the plural', 'msgstr[0]' => 'the singular' },
+ 2
+ )
+ errors = []
+
+ linter.validate_number_of_plurals(errors, fake_entry)
+
+ expect(errors).to include('should have 2 translations')
+ end
+ end
+
+ describe '#validate_variables' do
+ it 'validates both signular and plural in a pluralized string when the entry has a singular' do
+ pluralized_entry = Gitlab::I18n::TranslationEntry.new(
+ { msgid: 'Hello %{world}',
+ msgid_plural: 'Hello all %{world}',
+ 'msgstr[0]' => 'Bonjour %{world}',
+ 'msgstr[1]' => 'Bonjour tous %{world}' },
+ 2
+ )
+
+ expect(linter).to receive(:validate_variables_in_message)
+ .with([], 'Hello %{world}', 'Bonjour %{world}')
+ .and_call_original
+ expect(linter).to receive(:validate_variables_in_message)
+ .with([], 'Hello all %{world}', 'Bonjour tous %{world}')
+ .and_call_original
+
+ linter.validate_variables([], pluralized_entry)
+ end
+
+ it 'only validates plural when there is no separate singular' do
+ pluralized_entry = Gitlab::I18n::TranslationEntry.new(
+ { msgid: 'Hello %{world}',
+ msgid_plural: 'Hello all %{world}',
+ 'msgstr[0]' => 'Bonjour %{world}' },
+ 1
+ )
+
+ expect(linter).to receive(:validate_variables_in_message)
+ .with([], 'Hello all %{world}', 'Bonjour %{world}')
+
+ linter.validate_variables([], pluralized_entry)
+ end
+
+ it 'validates the message variables' do
+ entry = Gitlab::I18n::TranslationEntry.new(
+ { msgid: 'Hello', msgstr: 'Bonjour' },
+ 2
+ )
+
+ expect(linter).to receive(:validate_variables_in_message)
+ .with([], 'Hello', 'Bonjour')
+
+ linter.validate_variables([], entry)
+ end
+ end
+
+ describe '#validate_variables_in_message' do
+ it 'detects when a variables are used incorrectly' do
+ errors = []
+
+ expected_errors = ['<hello %{world} %d> is missing: [%{hello}]',
+ '<hello %{world} %d> is using unknown variables: [%{world}]',
+ 'is combining multiple unnamed variables']
+
+ linter.validate_variables_in_message(errors, '%{hello} world %d', 'hello %{world} %d')
+
+ expect(errors).to include(*expected_errors)
+ end
+ end
+
+ describe '#validate_translation' do
+ it 'succeeds with valid variables' do
+ errors = []
+
+ linter.validate_translation(errors, 'Hello %{world}', ['%{world}'])
+
+ expect(errors).to be_empty
+ end
+
+ it 'adds an error message when translating fails' do
+ errors = []
+
+ expect(FastGettext::Translation).to receive(:_) { raise 'broken' }
+
+ linter.validate_translation(errors, 'Hello', [])
+
+ expect(errors).to include('Failure translating to en with []: broken')
+ end
+
+ it 'adds an error message when translating fails when translating with context' do
+ errors = []
+
+ expect(FastGettext::Translation).to receive(:s_) { raise 'broken' }
+
+ linter.validate_translation(errors, 'Tests|Hello', [])
+
+ expect(errors).to include('Failure translating to en with []: broken')
+ end
+
+ it "adds an error when trying to translate with incorrect variables when using unnamed variables" do
+ errors = []
+
+ linter.validate_translation(errors, 'Hello %d', ['%s'])
+
+ expect(errors.first).to start_with("Failure translating to en with")
+ end
+
+ it "adds an error when trying to translate with named variables when unnamed variables are expected" do
+ errors = []
+
+ linter.validate_translation(errors, 'Hello %d', ['%{world}'])
+
+ expect(errors.first).to start_with("Failure translating to en with")
+ end
+
+ it 'adds an error when translated with incorrect variables using named variables' do
+ errors = []
+
+ linter.validate_translation(errors, 'Hello %{thing}', ['%d'])
+
+ expect(errors.first).to start_with("Failure translating to en with")
+ end
+ end
+
+ describe '#fill_in_variables' do
+ it 'builds an array for %d translations' do
+ result = linter.fill_in_variables(['%d'])
+
+ expect(result).to contain_exactly(a_kind_of(Integer))
+ end
+
+ it 'builds an array for %s translations' do
+ result = linter.fill_in_variables(['%s'])
+
+ expect(result).to contain_exactly(a_kind_of(String))
+ end
+
+ it 'builds a hash for named variables' do
+ result = linter.fill_in_variables(['%{hello}'])
+
+ expect(result).to be_a(Hash)
+ expect(result).to include('hello' => an_instance_of(String))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/i18n/translation_entry_spec.rb b/spec/lib/gitlab/i18n/translation_entry_spec.rb
new file mode 100644
index 00000000000..f68bc8feff9
--- /dev/null
+++ b/spec/lib/gitlab/i18n/translation_entry_spec.rb
@@ -0,0 +1,203 @@
+require 'spec_helper'
+
+describe Gitlab::I18n::TranslationEntry do
+ describe '#singular_translation' do
+ it 'returns the normal `msgstr` for translations without plural' do
+ data = { msgid: 'Hello world', msgstr: 'Bonjour monde' }
+ entry = described_class.new(data, 2)
+
+ expect(entry.singular_translation).to eq('Bonjour monde')
+ end
+
+ it 'returns the first string for entries with plurals' do
+ data = {
+ msgid: 'Hello world',
+ msgid_plural: 'Hello worlds',
+ 'msgstr[0]' => 'Bonjour monde',
+ 'msgstr[1]' => 'Bonjour mondes'
+ }
+ entry = described_class.new(data, 2)
+
+ expect(entry.singular_translation).to eq('Bonjour monde')
+ end
+ end
+
+ describe '#all_translations' do
+ it 'returns all translations for singular translations' do
+ data = { msgid: 'Hello world', msgstr: 'Bonjour monde' }
+ entry = described_class.new(data, 2)
+
+ expect(entry.all_translations).to eq(['Bonjour monde'])
+ end
+
+ it 'returns all translations when including plural translations' do
+ data = {
+ msgid: 'Hello world',
+ msgid_plural: 'Hello worlds',
+ 'msgstr[0]' => 'Bonjour monde',
+ 'msgstr[1]' => 'Bonjour mondes'
+ }
+ entry = described_class.new(data, 2)
+
+ expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes'])
+ end
+ end
+
+ describe '#plural_translations' do
+ it 'returns all translations if there is only one plural' do
+ data = {
+ msgid: 'Hello world',
+ msgid_plural: 'Hello worlds',
+ 'msgstr[0]' => 'Bonjour monde'
+ }
+ entry = described_class.new(data, 1)
+
+ expect(entry.plural_translations).to eq(['Bonjour monde'])
+ end
+
+ it 'returns all translations except for the first one if there are multiple' do
+ data = {
+ msgid: 'Hello world',
+ msgid_plural: 'Hello worlds',
+ 'msgstr[0]' => 'Bonjour monde',
+ 'msgstr[1]' => 'Bonjour mondes',
+ 'msgstr[2]' => 'Bonjour tous les mondes'
+ }
+ entry = described_class.new(data, 3)
+
+ expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes'])
+ end
+ end
+
+ describe '#has_singular_translation?' do
+ it 'has a singular when the translation is not pluralized' do
+ data = {
+ msgid: 'hello world',
+ msgstr: 'hello'
+ }
+ entry = described_class.new(data, 2)
+
+ expect(entry).to have_singular_translation
+ end
+
+ it 'has a singular when plural and singular are separately defined' do
+ data = {
+ msgid: 'hello world',
+ msgid_plural: 'hello worlds',
+ "msgstr[0]" => 'hello world',
+ "msgstr[1]" => 'hello worlds'
+ }
+ entry = described_class.new(data, 2)
+
+ expect(entry).to have_singular_translation
+ end
+
+ it 'does not have a separate singular if the plural string only has one translation' do
+ data = {
+ msgid: 'hello world',
+ msgid_plural: 'hello worlds',
+ "msgstr[0]" => 'hello worlds'
+ }
+ entry = described_class.new(data, 1)
+
+ expect(entry).not_to have_singular_translation
+ end
+ end
+
+ describe '#msgid_contains_newlines' do
+ it 'is true when the msgid is an array' do
+ data = { msgid: %w(hello world) }
+ entry = described_class.new(data, 2)
+
+ expect(entry.msgid_contains_newlines?).to be_truthy
+ end
+ end
+
+ describe '#plural_id_contains_newlines' do
+ it 'is true when the msgid is an array' do
+ data = { msgid_plural: %w(hello world) }
+ entry = described_class.new(data, 2)
+
+ expect(entry.plural_id_contains_newlines?).to be_truthy
+ end
+ end
+
+ describe '#translations_contain_newlines' do
+ it 'is true when the msgid is an array' do
+ data = { msgstr: %w(hello world) }
+ entry = described_class.new(data, 2)
+
+ expect(entry.translations_contain_newlines?).to be_truthy
+ end
+ end
+
+ describe '#contains_unescaped_chars' do
+ let(:data) { { msgid: '' } }
+ let(:entry) { described_class.new(data, 2) }
+ it 'is true when the msgid is an array' do
+ string = '「100%確定」'
+
+ expect(entry.contains_unescaped_chars?(string)).to be_truthy
+ end
+
+ it 'is false when the `%` char is escaped' do
+ string = '「100%%確定」'
+
+ expect(entry.contains_unescaped_chars?(string)).to be_falsy
+ end
+
+ it 'is false when using an unnamed variable' do
+ string = '「100%d確定」'
+
+ expect(entry.contains_unescaped_chars?(string)).to be_falsy
+ end
+
+ it 'is false when using a named variable' do
+ string = '「100%{named}確定」'
+
+ expect(entry.contains_unescaped_chars?(string)).to be_falsy
+ end
+
+ it 'is true when an unnamed variable is not closed' do
+ string = '「100%{named確定」'
+
+ expect(entry.contains_unescaped_chars?(string)).to be_truthy
+ end
+
+ it 'is true when the string starts with a `%`' do
+ string = '%10'
+
+ expect(entry.contains_unescaped_chars?(string)).to be_truthy
+ end
+ end
+
+ describe '#msgid_contains_unescaped_chars' do
+ it 'is true when the msgid contains a `%`' do
+ data = { msgid: '「100%確定」' }
+ entry = described_class.new(data, 2)
+
+ expect(entry).to receive(:contains_unescaped_chars?).and_call_original
+ expect(entry.msgid_contains_unescaped_chars?).to be_truthy
+ end
+ end
+
+ describe '#plural_id_contains_unescaped_chars' do
+ it 'is true when the plural msgid contains a `%`' do
+ data = { msgid_plural: '「100%確定」' }
+ entry = described_class.new(data, 2)
+
+ expect(entry).to receive(:contains_unescaped_chars?).and_call_original
+ expect(entry.plural_id_contains_unescaped_chars?).to be_truthy
+ end
+ end
+
+ describe '#translations_contain_unescaped_chars' do
+ it 'is true when the translation contains a `%`' do
+ data = { msgstr: '「100%確定」' }
+ entry = described_class.new(data, 2)
+
+ expect(entry).to receive(:contains_unescaped_chars?).and_call_original
+ expect(entry.translations_contain_unescaped_chars?).to be_truthy
+ end
+ end
+end
diff --git a/spec/lib/gitlab/key_fingerprint_spec.rb b/spec/lib/gitlab/key_fingerprint_spec.rb
deleted file mode 100644
index d643dc5342d..00000000000
--- a/spec/lib/gitlab/key_fingerprint_spec.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::KeyFingerprint, lib: true do
- KEYS = {
- rsa:
- 'example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC5z65PwQ1GE6foJgwk' \
- '9rmQi/glaXbUeVa5uvQpnZ3Z5+forcI7aTngh3aZ/H2UDP2L70TGy7kKNyp0J3a8/OdG' \
- 'Z08y5yi3JlbjFARO1NyoFEjw2H1SJxeJ43L6zmvTlu+hlK1jSAlidl7enS0ufTlzEEj4' \
- 'iJcuTPKdVzKRgZuTRVm9woWNVKqIrdRC0rJiTinERnfSAp/vNYERMuaoN4oJt8p/NEek' \
- 'rmFoDsQOsyDW5RAnCnjWUU+jFBKDpfkJQ1U2n6BjJewC9dl6ODK639l3yN4WOLZEk4tN' \
- 'UysfbGeF3rmMeflaD6O1Jplpv3YhwVGFNKa7fMq6k3Z0tszTJPYh',
- ecdsa:
- 'example.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' \
- 'bmlzdHAyNTYAAABBBKTJy43NZzJSfNxpv/e2E6Zy3qoHoTQbmOsU5FEfpWfWa1MdTeXQ' \
- 'YvKOi+qz/1AaNx6BK421jGu74JCDJtiZWT8=',
- ed25519:
- '@revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjq' \
- 'uxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf',
- dss:
- 'example.com ssh-dss AAAAB3NzaC1kc3MAAACBAP1/U4EddRIpUt9KnC7s5Of2EbdS' \
- 'PO9EAMMeP4C2USZpRV1AIlH7WT2NWPq/xfW6MPbLm1Vs14E7gB00b/JmYLdrmVClpJ+f' \
- '6AR7ECLCT7up1/63xhv4O1fnxqimFQ8E+4P208UewwI1VBNaFpEy9nXzrith1yrv8iID' \
- 'GZ3RSAHHAAAAFQCXYFCPFSMLzLKSuYKi64QL8Fgc9QAAAIEA9+GghdabPd7LvKtcNrhX' \
- 'uXmUr7v6OuqC+VdMCz0HgmdRWVeOutRZT+ZxBxCBgLRJFnEj6EwoFhO3zwkyjMim4TwW' \
- 'eotUfI0o4KOuHiuzpnWRbqN/C/ohNWLx+2J6ASQ7zKTxvqhRkImog9/hWuWfBpKLZl6A' \
- 'e1UlZAFMO/7PSSoAAACBAJcQ4JODqhuGbXIEpqxetm7PWbdbCcr3y/GzIZ066pRovpL6' \
- 'qm3qCVIym4cyChxWwb8qlyCIi+YRUUWm1z/wiBYT2Vf3S4FXBnyymCkKEaV/EY7+jd4X' \
- '1bXI58OD2u+bLCB/sInM4fGB8CZUIWT9nJH0Ve9jJUge2ms348/QOJ1+'
- }.freeze
-
- MD5_FINGERPRINTS = {
- rsa: '06:b2:8a:92:df:0e:11:2c:ca:7b:8f:a4:ba:6e:4b:fd',
- ecdsa: '45:ff:5b:98:9a:b6:8a:41:13:c1:30:8b:09:5e:7b:4e',
- ed25519: '2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16',
- dss: '57:98:86:02:5f:9c:f4:9b:ad:5a:1e:51:92:0e:fd:2b'
- }.freeze
-
- BIT_COUNTS = {
- rsa: 2048,
- ecdsa: 256,
- ed25519: 256,
- dss: 1024
- }.freeze
-
- describe '#type' do
- KEYS.each do |type, key|
- it "calculates the type of #{type} keys" do
- calculated_type = described_class.new(key).type
-
- expect(calculated_type).to eq(type.to_s.upcase)
- end
- end
- end
-
- describe '#fingerprint' do
- KEYS.each do |type, key|
- it "calculates the MD5 fingerprint for #{type} keys" do
- fp = described_class.new(key).fingerprint
-
- expect(fp).to eq(MD5_FINGERPRINTS[type])
- end
- end
- end
-
- describe '#bits' do
- KEYS.each do |type, key|
- it "calculates the number of bits in #{type} keys" do
- bits = described_class.new(key).bits
-
- expect(bits).to eq(BIT_COUNTS[type])
- end
- end
- end
-
- describe '#key' do
- it 'carries the unmodified key data' do
- key = described_class.new(KEYS[:rsa]).key
-
- expect(key).to eq(KEYS[:rsa])
- end
- end
-end
diff --git a/spec/lib/gitlab/sentry_spec.rb b/spec/lib/gitlab/sentry_spec.rb
new file mode 100644
index 00000000000..8c211d1c63f
--- /dev/null
+++ b/spec/lib/gitlab/sentry_spec.rb
@@ -0,0 +1,13 @@
+require 'spec_helper'
+
+describe Gitlab::Sentry do
+ describe '.context' do
+ it 'adds the locale to the tags' do
+ expect(described_class).to receive(:enabled?).and_return(true)
+
+ described_class.context(nil)
+
+ expect(Raven.tags_context[:locale]).to eq(I18n.locale.to_s)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb
new file mode 100644
index 00000000000..93d538141ce
--- /dev/null
+++ b/spec/lib/gitlab/ssh_public_key_spec.rb
@@ -0,0 +1,136 @@
+require 'spec_helper'
+
+describe Gitlab::SSHPublicKey, lib: true do
+ let(:key) { attributes_for(:rsa_key_2048)[:key] }
+ let(:public_key) { described_class.new(key) }
+
+ describe '.technology(name)' do
+ it 'returns nil for an unrecognised name' do
+ expect(described_class.technology(:foo)).to be_nil
+ end
+
+ where(:name) do
+ [:rsa, :dsa, :ecdsa, :ed25519]
+ end
+
+ with_them do
+ it { expect(described_class.technology(name).name).to eq(name) }
+ it { expect(described_class.technology(name.to_s).name).to eq(name) }
+ end
+ end
+
+ describe '.supported_sizes(name)' do
+ where(:name, :sizes) do
+ [
+ [:rsa, [1024, 2048, 3072, 4096]],
+ [:dsa, [1024, 2048, 3072]],
+ [:ecdsa, [256, 384, 521]],
+ [:ed25519, [256]]
+ ]
+ end
+
+ subject { described_class.supported_sizes(name) }
+
+ with_them do
+ it { expect(described_class.supported_sizes(name)).to eq(sizes) }
+ it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) }
+ end
+ end
+
+ describe '#valid?' do
+ subject { public_key }
+
+ context 'with a valid SSH key' do
+ it { is_expected.to be_valid }
+ end
+
+ context 'with an invalid SSH key' do
+ let(:key) { 'this is not a key' }
+
+ it { is_expected.not_to be_valid }
+ end
+ end
+
+ describe '#type' do
+ subject { public_key.type }
+
+ where(:factory, :type) do
+ [
+ [:rsa_key_2048, :rsa],
+ [:dsa_key_2048, :dsa],
+ [:ecdsa_key_256, :ecdsa],
+ [:ed25519_key_256, :ed25519]
+ ]
+ end
+
+ with_them do
+ let(:key) { attributes_for(factory)[:key] }
+
+ it { is_expected.to eq(type) }
+ end
+
+ context 'with an invalid SSH key' do
+ let(:key) { 'this is not a key' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#bits' do
+ subject { public_key.bits }
+
+ where(:factory, :bits) do
+ [
+ [:rsa_key_2048, 2048],
+ [:dsa_key_2048, 2048],
+ [:ecdsa_key_256, 256],
+ [:ed25519_key_256, 256]
+ ]
+ end
+
+ with_them do
+ let(:key) { attributes_for(factory)[:key] }
+
+ it { is_expected.to eq(bits) }
+ end
+
+ context 'with an invalid SSH key' do
+ let(:key) { 'this is not a key' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#fingerprint' do
+ subject { public_key.fingerprint }
+
+ where(:factory, :fingerprint) do
+ [
+ [:rsa_key_2048, '2e:ca:dc:e0:37:29:ed:fc:f0:1d:bf:66:d4:cd:51:b1'],
+ [:dsa_key_2048, 'bc:c1:a4:be:7e:8c:84:56:b3:58:93:53:c6:80:78:8c'],
+ [:ecdsa_key_256, '67:a3:a9:7d:b8:e1:15:d4:80:40:21:34:bb:ed:97:38'],
+ [:ed25519_key_256, 'e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73']
+ ]
+ end
+
+ with_them do
+ let(:key) { attributes_for(factory)[:key] }
+
+ it { is_expected.to eq(fingerprint) }
+ end
+
+ context 'with an invalid SSH key' do
+ let(:key) { 'this is not a key' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#key_text' do
+ let(:key) { 'this is not a key' }
+
+ it 'carries the unmodified key data' do
+ expect(public_key.key_text).to eq(key)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb
index 92787bb262e..3137a72fdc4 100644
--- a/spec/lib/gitlab/utils_spec.rb
+++ b/spec/lib/gitlab/utils_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Utils do
- delegate :to_boolean, :boolean_to_yes_no, :slugify, to: :described_class
+ delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, to: :described_class
describe '.slugify' do
{
@@ -53,4 +53,10 @@ describe Gitlab::Utils do
expect(boolean_to_yes_no(false)).to eq('No')
end
end
+
+ describe '.random_string' do
+ it 'generates a string' do
+ expect(random_string).to be_kind_of(String)
+ end
+ end
end
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index b66afafa174..699184ad9fe 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -228,21 +228,10 @@ describe Gitlab::Workhorse do
let(:action) { 'git_upload_pack' }
let(:feature_flag) { :post_upload_pack }
- context 'when action is enabled by feature flag' do
- it 'includes Gitaly params in the returned value' do
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag).and_return(true)
+ it 'includes Gitaly params in the returned value' do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag).and_return(true)
- expect(subject).to include(gitaly_params)
- end
- end
-
- context 'when action is not enabled by feature flag' do
- it 'does not include Gitaly params in the returned value' do
- status_opt_out = Gitlab::GitalyClient::MigrationStatus::OPT_OUT
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag, status: status_opt_out).and_return(false)
-
- expect(subject).not_to include(gitaly_params)
- end
+ expect(subject).to include(gitaly_params)
end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 1fa59ebd22b..932e2fd8c95 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -8,6 +8,25 @@ describe Notify do
include_context 'gitlab email notification'
+ set(:user) { create(:user) }
+ set(:current_user) { create(:user, email: "current@email.com") }
+ set(:assignee) { create(:user, email: 'assignee@example.com', name: 'John Doe') }
+
+ set(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project,
+ author: current_user,
+ assignee: assignee,
+ description: 'Awesome description')
+ end
+
+ set(:issue) do
+ create(:issue, author: current_user,
+ assignees: [assignee],
+ project: project,
+ description: 'My awesome description!')
+ end
+
def have_referable_subject(referable, reply: false)
prefix = referable.project.name if referable.project
prefix = "Re: #{prefix}" if reply
@@ -19,8 +38,6 @@ describe Notify do
context 'for a project' do
describe 'items that are assignable, the email' do
- let(:current_user) { create(:user, email: "current@email.com") }
- let(:assignee) { create(:user, email: 'assignee@example.com', name: 'John Doe') }
let(:previous_assignee) { create(:user, name: 'Previous Assignee') }
shared_examples 'an assignee email' do
@@ -36,9 +53,6 @@ describe Notify do
end
context 'for issues' do
- let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) }
- let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') }
-
describe 'that are new' do
subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) }
@@ -56,6 +70,10 @@ describe Notify do
end
end
+ it 'contains the description' do
+ is_expected.to have_html_escaped_body_text issue.description
+ end
+
context 'when enabled email_author_in_body' do
before do
stub_application_setting(email_author_in_body: true)
@@ -68,16 +86,6 @@ describe Notify do
end
end
- describe 'that are new with a description' do
- subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) }
-
- it_behaves_like 'it should show Gmail Actions View Issue link'
-
- it 'contains the description' do
- is_expected.to have_html_escaped_body_text issue_with_description.description
- end
- end
-
describe 'that have been reassigned' do
subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) }
@@ -197,11 +205,6 @@ describe Notify do
end
context 'for merge requests' do
- let(:project) { create(:project, :repository) }
- let(:merge_author) { create(:user) }
- let(:merge_request) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project) }
- let(:merge_request_with_description) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project, description: 'My awesome description') }
-
describe 'that are new' do
subject { described_class.new_merge_request_email(merge_request.assignee_id, merge_request.id) }
@@ -221,6 +224,10 @@ describe Notify do
end
end
+ it 'contains the description' do
+ is_expected.to have_html_escaped_body_text merge_request.description
+ end
+
context 'when enabled email_author_in_body' do
before do
stub_application_setting(email_author_in_body: true)
@@ -233,17 +240,6 @@ describe Notify do
end
end
- describe 'that are new with a description' do
- subject { described_class.new_merge_request_email(merge_request_with_description.assignee_id, merge_request_with_description.id) }
-
- it_behaves_like 'it should show Gmail Actions View Merge request link'
- it_behaves_like "an unsubscribeable thread"
-
- it 'contains the description' do
- is_expected.to have_html_escaped_body_text merge_request_with_description.description
- end
- end
-
describe 'that are reassigned' do
subject { described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) }
@@ -321,6 +317,7 @@ describe Notify do
end
describe 'that are merged' do
+ let(:merge_author) { create(:user) }
subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
it_behaves_like 'a multiple recipients email'
@@ -348,8 +345,6 @@ describe Notify do
end
describe 'project was moved' do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
subject { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
it_behaves_like 'an email sent from GitLab'
@@ -371,7 +366,6 @@ describe Notify do
end
end
- let(:user) { create(:user) }
let(:project_member) do
project.request_access(user)
project.requesters.find_by(user_id: user.id)
@@ -398,7 +392,6 @@ describe Notify do
let(:group_owner) { create(:user) }
let(:group) { create(:group).tap { |g| g.add_owner(group_owner) } }
let(:project) { create(:project, :public, :access_requestable, namespace: group) }
- let(:user) { create(:user) }
let(:project_member) do
project.request_access(user)
project.requesters.find_by(user_id: user.id)
@@ -424,7 +417,6 @@ describe Notify do
describe 'project access denied' do
let(:project) { create(:project, :public, :access_requestable) }
- let(:user) { create(:user) }
let(:project_member) do
project.request_access(user)
project.requesters.find_by(user_id: user.id)
@@ -445,7 +437,6 @@ describe Notify do
describe 'project access changed' do
let(:owner) { create(:user, name: "Chang O'Keefe") }
let(:project) { create(:project, :public, :access_requestable, namespace: owner.namespace) }
- let(:user) { create(:user) }
let(:project_member) { create(:project_member, project: project, user: user) }
subject { described_class.member_access_granted_email('project', project_member.id) }
@@ -474,7 +465,6 @@ describe Notify do
end
describe 'project invitation' do
- let(:project) { create(:project) }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:project_member) { invite_to_project(project, inviter: master) }
@@ -494,7 +484,6 @@ describe Notify do
end
describe 'project invitation accepted' do
- let(:project) { create(:project) }
let(:invited_user) { create(:user, name: 'invited user') }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:project_member) do
@@ -519,7 +508,6 @@ describe Notify do
end
describe 'project invitation declined' do
- let(:project) { create(:project) }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:project_member) do
invitee = invite_to_project(project, inviter: master)
@@ -582,7 +570,6 @@ describe Notify do
end
describe 'on a commit' do
- let(:project) { create(:project, :repository) }
let(:commit) { project.commit }
before do
@@ -607,7 +594,6 @@ describe Notify do
end
describe 'on a merge request' do
- let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:note_on_merge_request_path) { project_merge_request_path(project, merge_request, anchor: "note_#{note.id}") }
before do
@@ -632,7 +618,6 @@ describe Notify do
end
describe 'on an issue' do
- let(:issue) { create(:issue, project: project) }
let(:note_on_issue_path) { project_issue_path(project, issue, anchor: "note_#{note.id}") }
before do
@@ -658,7 +643,6 @@ describe Notify do
end
context 'items that are noteable, the email for a discussion note' do
- let(:project) { create(:project, :repository) }
let(:note_author) { create(:user, name: 'author_name') }
before do
@@ -722,7 +706,6 @@ describe Notify do
end
describe 'on a merge request' do
- let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note_author) }
let(:note_on_merge_request_path) { project_merge_request_path(project, merge_request, anchor: "note_#{note.id}") }
@@ -749,7 +732,6 @@ describe Notify do
end
describe 'on an issue' do
- let(:issue) { create(:issue, project: project) }
let(:note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: note_author) }
let(:note_on_issue_path) { project_issue_path(project, issue, anchor: "note_#{note.id}") }
@@ -835,7 +817,6 @@ describe Notify do
end
describe 'on a merge request' do
- let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:note) { create(:diff_note_on_merge_request) }
subject { described_class.note_merge_request_email(recipient.id, note.id) }
@@ -848,9 +829,10 @@ describe Notify do
end
context 'for a group' do
+ set(:group) { create(:group) }
+
describe 'group access requested' do
let(:group) { create(:group, :public, :access_requestable) }
- let(:user) { create(:user) }
let(:group_member) do
group.request_access(user)
group.requesters.find_by(user_id: user.id)
@@ -870,8 +852,6 @@ describe Notify do
end
describe 'group access denied' do
- let(:group) { create(:group) }
- let(:user) { create(:user) }
let(:group_member) do
group.request_access(user)
group.requesters.find_by(user_id: user.id)
@@ -890,8 +870,6 @@ describe Notify do
end
describe 'group access changed' do
- let(:group) { create(:group) }
- let(:user) { create(:user) }
let(:group_member) { create(:group_member, group: group, user: user) }
subject { described_class.member_access_granted_email('group', group_member.id) }
@@ -921,7 +899,6 @@ describe Notify do
end
describe 'group invitation' do
- let(:group) { create(:group) }
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) { invite_to_group(group, inviter: owner) }
@@ -941,7 +918,6 @@ describe Notify do
end
describe 'group invitation accepted' do
- let(:group) { create(:group) }
let(:invited_user) { create(:user, name: 'invited user') }
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) do
@@ -966,7 +942,6 @@ describe Notify do
end
describe 'group invitation declined' do
- let(:group) { create(:group) }
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) do
invitee = invite_to_group(group, inviter: owner)
@@ -1020,7 +995,6 @@ describe Notify do
describe 'email on push for a created branch' do
let(:example_site_path) { root_path }
- let(:user) { create(:user) }
let(:tree_path) { project_tree_path(project, "empty-branch") }
subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/empty-branch', action: :create) }
@@ -1046,7 +1020,6 @@ describe Notify do
describe 'email on push for a created tag' do
let(:example_site_path) { root_path }
- let(:user) { create(:user) }
let(:tree_path) { project_tree_path(project, "v1.0") }
subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :create) }
@@ -1072,7 +1045,6 @@ describe Notify do
describe 'email on push for a deleted branch' do
let(:example_site_path) { root_path }
- let(:user) { create(:user) }
subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) }
@@ -1094,7 +1066,6 @@ describe Notify do
describe 'email on push for a deleted tag' do
let(:example_site_path) { root_path }
- let(:user) { create(:user) }
subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) }
@@ -1115,9 +1086,7 @@ describe Notify do
end
describe 'email on push with multiple commits' do
- let(:project) { create(:project, :repository) }
let(:example_site_path) { root_path }
- let(:user) { create(:user) }
let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_image_commit.id, sample_commit.id) }
let(:compare) { Compare.decorate(raw_compare, project) }
let(:commits) { compare.commits }
@@ -1209,9 +1178,7 @@ describe Notify do
end
describe 'email on push with a single commit' do
- let(:project) { create(:project, :repository) }
let(:example_site_path) { root_path }
- let(:user) { create(:user) }
let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_commit.parent_id, sample_commit.id) }
let(:compare) { Compare.decorate(raw_compare, project) }
let(:commits) { compare.commits }
@@ -1242,8 +1209,6 @@ describe Notify do
end
describe 'HTML emails setting' do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
let(:multipart_mail) { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
context 'when disabled' do
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 359753b600e..f921545668d 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -72,6 +72,33 @@ describe ApplicationSetting do
.is_greater_than(0)
end
+ context 'key restrictions' do
+ it 'supports all key types' do
+ expect(described_class::SUPPORTED_KEY_TYPES).to contain_exactly(:rsa, :dsa, :ecdsa, :ed25519)
+ end
+
+ it 'does not allow all key types to be disabled' do
+ described_class::SUPPORTED_KEY_TYPES.each do |type|
+ setting["#{type}_key_restriction"] = described_class::FORBIDDEN_KEY_VALUE
+ end
+
+ expect(setting).not_to be_valid
+ expect(setting.errors.messages).to have_key(:allowed_key_types)
+ end
+
+ where(:type) do
+ described_class::SUPPORTED_KEY_TYPES
+ end
+
+ with_them do
+ let(:field) { :"#{type}_key_restriction" }
+
+ it { is_expected.to validate_presence_of(field) }
+ it { is_expected.to allow_value(*KeyRestrictionValidator.supported_key_restrictions(type)).for(field) }
+ it { is_expected.not_to allow_value(128).for(field) }
+ end
+ end
+
it_behaves_like 'an object with email-formated attributes', :admin_notification_email do
subject { setting }
end
@@ -441,4 +468,36 @@ describe ApplicationSetting do
end
end
end
+
+ describe '#allowed_key_types' do
+ it 'includes all key types by default' do
+ expect(setting.allowed_key_types).to contain_exactly(*described_class::SUPPORTED_KEY_TYPES)
+ end
+
+ it 'excludes disabled key types' do
+ expect(setting.allowed_key_types).to include(:ed25519)
+
+ setting.ed25519_key_restriction = described_class::FORBIDDEN_KEY_VALUE
+
+ expect(setting.allowed_key_types).not_to include(:ed25519)
+ end
+ end
+
+ describe '#key_restriction_for' do
+ it 'returns the restriction value for recognised types' do
+ setting.rsa_key_restriction = 1024
+
+ expect(setting.key_restriction_for(:rsa)).to eq(1024)
+ end
+
+ it 'allows types to be passed as a string' do
+ setting.rsa_key_restriction = 1024
+
+ expect(setting.key_restriction_for('rsa')).to eq(1024)
+ end
+
+ it 'returns forbidden for unrecognised type' do
+ expect(setting.key_restriction_for(:foo)).to eq(described_class::FORBIDDEN_KEY_VALUE)
+ end
+ end
end
diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb
index 87e60d9c16b..b909e04dfc3 100644
--- a/spec/models/award_emoji_spec.rb
+++ b/spec/models/award_emoji_spec.rb
@@ -41,4 +41,40 @@ describe AwardEmoji do
end
end
end
+
+ describe 'expiring ETag cache' do
+ context 'on a note' do
+ let(:note) { create(:note_on_issue) }
+ let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: note) }
+
+ it 'calls expire_etag_cache on the note when saved' do
+ expect(note).to receive(:expire_etag_cache)
+
+ award_emoji.save!
+ end
+
+ it 'calls expire_etag_cache on the note when destroyed' do
+ expect(note).to receive(:expire_etag_cache)
+
+ award_emoji.destroy!
+ end
+ end
+
+ context 'on another awardable' do
+ let(:issue) { create(:issue) }
+ let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: issue) }
+
+ it 'does not call expire_etag_cache on the issue when saved' do
+ expect(issue).not_to receive(:expire_etag_cache)
+
+ award_emoji.save!
+ end
+
+ it 'does not call expire_etag_cache on the issue when destroyed' do
+ expect(issue).not_to receive(:expire_etag_cache)
+
+ award_emoji.destroy!
+ end
+ end
+ end
end
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index c18c635d811..11e64a0f877 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -195,6 +195,67 @@ eos
it { expect(data[:removed]).to eq([]) }
end
+ describe '#cherry_pick_message' do
+ let(:user) { create(:user) }
+
+ context 'of a regular commit' do
+ let(:commit) { project.commit('video') }
+
+ it { expect(commit.cherry_pick_message(user)).to include("\n\n(cherry picked from commit 88790590ed1337ab189bccaa355f068481c90bec)") }
+ end
+
+ context 'of a merge commit' do
+ let(:repository) { project.repository }
+
+ let(:commit_options) do
+ author = repository.user_to_committer(user)
+ { message: 'Test message', committer: author, author: author }
+ end
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_branch: 'video',
+ target_branch: 'master',
+ source_project: project,
+ author: user)
+ end
+
+ let(:merge_commit) do
+ merge_commit_id = repository.merge(user,
+ merge_request.diff_head_sha,
+ merge_request,
+ commit_options)
+
+ repository.commit(merge_commit_id)
+ end
+
+ context 'that is found' do
+ before do
+ # Artificially mark as completed.
+ merge_request.update(merge_commit_sha: merge_commit.id)
+ end
+
+ it do
+ expected_appended_text = <<~STR.rstrip
+
+ (cherry picked from commit #{merge_commit.sha})
+
+ 467dc98f Add new 'videos' directory
+ 88790590 Upload new video file
+ STR
+
+ expect(merge_commit.cherry_pick_message(user)).to include(expected_appended_text)
+ end
+ end
+
+ context "that is existing but not found" do
+ it 'does not include details of the merged commits' do
+ expect(merge_commit.cherry_pick_message(user)).to end_with("(cherry picked from commit #{merge_commit.sha})")
+ end
+ end
+ end
+ end
+
describe '#reverts_commit?' do
let(:another_commit) { double(:commit, revert_description: "This reverts commit #{commit.sha}") }
let(:user) { commit.author }
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 3508391c721..96baeaff0a4 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -1,6 +1,13 @@
require 'spec_helper'
describe Key, :mailer do
+ include Gitlab::CurrentSettings
+
+ describe 'modules' do
+ subject { described_class }
+ it { is_expected.to include_module(Gitlab::CurrentSettings) }
+ end
+
describe "Associations" do
it { is_expected.to belong_to(:user) }
end
@@ -11,8 +18,10 @@ describe Key, :mailer do
it { is_expected.to validate_presence_of(:key) }
it { is_expected.to validate_length_of(:key).is_at_most(5000) }
- it { is_expected.to allow_value('ssh-foo').for(:key) }
- it { is_expected.to allow_value('ecdsa-foo').for(:key) }
+ it { is_expected.to allow_value(attributes_for(:rsa_key_2048)[:key]).for(:key) }
+ it { is_expected.to allow_value(attributes_for(:dsa_key_2048)[:key]).for(:key) }
+ it { is_expected.to allow_value(attributes_for(:ecdsa_key_256)[:key]).for(:key) }
+ it { is_expected.to allow_value(attributes_for(:ed25519_key_256)[:key]).for(:key) }
it { is_expected.not_to allow_value('foo-bar').for(:key) }
end
@@ -95,6 +104,48 @@ describe Key, :mailer do
end
end
+ context 'validate it meets key restrictions' do
+ where(:factory, :minimum, :result) do
+ forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE
+
+ [
+ [:rsa_key_2048, 0, true],
+ [:dsa_key_2048, 0, true],
+ [:ecdsa_key_256, 0, true],
+ [:ed25519_key_256, 0, true],
+
+ [:rsa_key_2048, 1024, true],
+ [:rsa_key_2048, 2048, true],
+ [:rsa_key_2048, 4096, false],
+
+ [:dsa_key_2048, 1024, true],
+ [:dsa_key_2048, 2048, true],
+ [:dsa_key_2048, 4096, false],
+
+ [:ecdsa_key_256, 256, true],
+ [:ecdsa_key_256, 384, false],
+
+ [:ed25519_key_256, 256, true],
+ [:ed25519_key_256, 384, false],
+
+ [:rsa_key_2048, forbidden, false],
+ [:dsa_key_2048, forbidden, false],
+ [:ecdsa_key_256, forbidden, false],
+ [:ed25519_key_256, forbidden, false]
+ ]
+ end
+
+ with_them do
+ subject(:key) { build(factory) }
+
+ before do
+ stub_application_setting("#{key.public_key.type}_key_restriction" => minimum)
+ end
+
+ it { expect(key.valid?).to eq(result) }
+ end
+ end
+
context 'callbacks' do
it 'adds new key to authorized_file' do
key = build(:personal_key, id: 7)
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 09f3b97ec58..f5d079c27c4 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -159,6 +159,7 @@ describe MergeRequest do
before do
subject.project.has_external_issue_tracker = true
subject.project.save!
+ create(:jira_service, project: subject.project)
end
it 'does not cache issues from external trackers' do
@@ -166,6 +167,7 @@ describe MergeRequest do
commit = double('commit1', safe_message: "Fixes #{issue.to_reference}")
allow(subject).to receive(:commits).and_return([commit])
+ expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to raise_error
expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count)
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 34e1a955309..40875c8fb7e 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1379,8 +1379,11 @@ describe Repository, models: true do
it 'cherry-picks the changes' do
expect(repository.blob_at_branch('improve/awesome', 'foo/bar/.gitkeep')).to be_nil
- repository.cherry_pick(user, pickable_merge, 'improve/awesome')
+ cherry_pick_commit_sha = repository.cherry_pick(user, pickable_merge, 'improve/awesome')
+ cherry_pick_commit_message = project.commit(cherry_pick_commit_sha).message
+
expect(repository.blob_at_branch('improve/awesome', 'foo/bar/.gitkeep')).not_to be_nil
+ expect(cherry_pick_commit_message).to include('cherry picked from')
end
end
end
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index 40a222be24d..9ef8d117123 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -281,6 +281,12 @@ describe WikiPage do
@page.title = "Import-existing-repositories-into-GitLab"
expect(@page.title).to eq("Import existing repositories into GitLab")
end
+
+ it 'unescapes html' do
+ @page.title = 'foo &amp; bar'
+
+ expect(@page.title).to eq('foo & bar')
+ end
end
describe '#directory' do
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index dafe3f466a2..d3b48f948f6 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -804,7 +804,7 @@ describe API::Commits do
expect(response).to have_gitlab_http_status(201)
expect(response).to match_response_schema('public_api/v4/commit/basic')
expect(json_response['title']).to eq(commit.title)
- expect(json_response['message']).to eq(commit.message)
+ expect(json_response['message']).to eq(commit.cherry_pick_message(user))
expect(json_response['author_name']).to eq(commit.author_name)
expect(json_response['committer_name']).to eq(user.name)
end
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 737c028ad53..0b9a4b5c3db 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -19,6 +19,10 @@ describe API::Settings, 'Settings' do
expect(json_response['default_project_visibility']).to be_a String
expect(json_response['default_snippet_visibility']).to be_a String
expect(json_response['default_group_visibility']).to be_a String
+ expect(json_response['rsa_key_restriction']).to eq(0)
+ expect(json_response['dsa_key_restriction']).to eq(0)
+ expect(json_response['ecdsa_key_restriction']).to eq(0)
+ expect(json_response['ed25519_key_restriction']).to eq(0)
end
end
@@ -44,7 +48,11 @@ describe API::Settings, 'Settings' do
help_page_text: 'custom help text',
help_page_hide_commercial_content: true,
help_page_support_url: 'http://example.com/help',
- project_export_enabled: false
+ project_export_enabled: false,
+ rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE,
+ dsa_key_restriction: 2048,
+ ecdsa_key_restriction: 384,
+ ed25519_key_restriction: 256
expect(response).to have_http_status(200)
expect(json_response['default_projects_limit']).to eq(3)
@@ -61,6 +69,10 @@ describe API::Settings, 'Settings' do
expect(json_response['help_page_hide_commercial_content']).to be_truthy
expect(json_response['help_page_support_url']).to eq('http://example.com/help')
expect(json_response['project_export_enabled']).to be_falsey
+ expect(json_response['rsa_key_restriction']).to eq(ApplicationSetting::FORBIDDEN_KEY_VALUE)
+ expect(json_response['dsa_key_restriction']).to eq(2048)
+ expect(json_response['ecdsa_key_restriction']).to eq(384)
+ expect(json_response['ed25519_key_restriction']).to eq(256)
end
end
diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb
index 4a4a5dc5c7c..8fb96b3c7c5 100644
--- a/spec/requests/api/v3/commits_spec.rb
+++ b/spec/requests/api/v3/commits_spec.rb
@@ -474,7 +474,7 @@ describe API::V3::Commits do
expect(response).to have_http_status(201)
expect(json_response['title']).to eq(master_pickable_commit.title)
- expect(json_response['message']).to eq(master_pickable_commit.message)
+ expect(json_response['message']).to eq(master_pickable_commit.cherry_pick_message(user))
expect(json_response['author_name']).to eq(master_pickable_commit.author_name)
expect(json_response['committer_name']).to eq(user.name)
end
diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb
new file mode 100644
index 00000000000..3459cc72063
--- /dev/null
+++ b/spec/serializers/note_entity_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe NoteEntity do
+ include Gitlab::Routing
+
+ let(:request) { double('request', current_user: user, noteable: note.noteable) }
+
+ let(:entity) { described_class.new(note, request: request) }
+ let(:note) { create(:note) }
+ let(:user) { create(:user) }
+ subject { entity.as_json }
+
+ context 'basic note' do
+ it 'exposes correct elements' do
+ expect(subject).to include(:type, :author, :human_access, :note, :note_html, :current_user,
+ :discussion_id, :emoji_awardable, :award_emoji, :toggle_award_path, :report_abuse_path, :path, :attachment)
+ end
+
+ it 'does not expose elements for specific notes cases' do
+ expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name)
+ end
+
+ it 'exposes author correctly' do
+ expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path)
+ end
+
+ it 'does not expose web_url for author' do
+ expect(subject[:author]).not_to include(:web_url)
+ end
+ end
+
+ context 'when note was edited' do
+ before do
+ note.update(updated_at: 1.minute.from_now, updated_by: user)
+ end
+
+ it 'exposes last_edited_at and last_edited_by elements' do
+ expect(subject).to include(:last_edited_at, :last_edited_by)
+ end
+ end
+
+ context 'when note is a system note' do
+ before do
+ note.update(system: true)
+ end
+
+ it 'exposes system_note_icon_name element' do
+ expect(subject).to include(:system_note_icon_name)
+ end
+ end
+end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 34fb16edc84..85f46838351 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -510,6 +510,26 @@ describe Issues::UpdateService, :mailer do
end
end
+ context 'move issue to another project' do
+ let(:target_project) { create(:project) }
+
+ context 'valid project' do
+ before do
+ target_project.team << [user, :master]
+ end
+
+ it 'calls the move service with the proper issue and project' do
+ move_stub = instance_double(Issues::MoveService)
+ allow(Issues::MoveService).to receive(:new).and_return(move_stub)
+ allow(move_stub).to receive(:execute).with(issue, target_project).and_return(issue)
+
+ expect(move_stub).to receive(:execute).with(issue, target_project)
+
+ update_issue(target_project: target_project)
+ end
+ end
+ end
+
include_examples 'issuable update service' do
let(:open_issuable) { issue }
let(:closed_issuable) { create(:closed_issue, project: project) }
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 30fa0ee6873..6926ac85de3 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -1147,5 +1147,15 @@ describe QuickActions::InterpretService do
expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."])
end
end
+
+ describe 'move issue to another project command' do
+ let(:content) { '/move test/project' }
+
+ it 'includes the project name' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(["Moves this issue to test/project."])
+ end
+ end
end
end
diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb
index bb4542b1683..81cb94ab8c4 100644
--- a/spec/support/features/discussion_comments_shared_example.rb
+++ b/spec/support/features/discussion_comments_shared_example.rb
@@ -14,6 +14,8 @@ shared_examples 'discussion comments' do |resource_name|
find(submit_selector).click
+ wait_for_requests
+
find(comments_selector, match: :first)
new_comment = all(comments_selector).last
@@ -26,6 +28,7 @@ shared_examples 'discussion comments' do |resource_name|
find("#{form_selector} .note-textarea").send_keys('a')
find(close_selector).click
+ wait_for_requests
find(comments_selector, match: :first)
find("#{comments_selector}.system-note")
@@ -76,12 +79,22 @@ shared_examples 'discussion comments' do |resource_name|
it 'clicking the ul padding or divider should not change the text' do
find(menu_selector).trigger 'click'
- expect(page).to have_selector menu_selector
- expect(find(dropdown_selector)).to have_content 'Comment'
+ if resource_name == 'issue'
+ expect(find(dropdown_selector)).to have_content 'Comment'
+
+ find(toggle_selector).click
+ find("#{menu_selector} .divider").trigger 'click'
+ else
+ find(menu_selector).trigger 'click'
- find("#{menu_selector} .divider").trigger 'click'
+ expect(page).to have_selector menu_selector
+ expect(find(dropdown_selector)).to have_content 'Comment'
+
+ find("#{menu_selector} .divider").trigger 'click'
+
+ expect(page).to have_selector menu_selector
+ end
- expect(page).to have_selector menu_selector
expect(find(dropdown_selector)).to have_content 'Comment'
end
@@ -91,9 +104,8 @@ shared_examples 'discussion comments' do |resource_name|
all("#{menu_selector} li").last.click
end
- it 'updates the submit button text, note_type input and closes the dropdown' do
+ it 'updates the submit button text and closes the dropdown' do
expect(find(dropdown_selector)).to have_content 'Start discussion'
- expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote')
expect(page).not_to have_selector menu_selector
end
@@ -157,9 +169,8 @@ shared_examples 'discussion comments' do |resource_name|
find("#{menu_selector} li", match: :first).click
end
- it 'updates the submit button text, clears the note_type input and closes the dropdown' do
+ it 'updates the submit button text and closes the dropdown' do
expect(find(dropdown_selector)).to have_content 'Comment'
- expect(find("#{form_selector} #note_type", visible: false).value).to eq('')
expect(page).not_to have_selector menu_selector
end
diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb
index 68f0ce8afb3..8282ba7e536 100644
--- a/spec/support/features/issuable_slash_commands_shared_examples.rb
+++ b/spec/support/features/issuable_slash_commands_shared_examples.rb
@@ -21,7 +21,7 @@ shared_examples 'issuable record that supports quick actions in its description
before do
project.team << [master, :master]
- sign_in(master)
+ gitlab_sign_in(master)
end
after do
@@ -119,16 +119,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user)
project.add_guest(guest)
- sign_out(:user)
- sign_in(guest)
-
+ gitlab_sign_out
+ gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not close the #{issuable_type}" do
write_note("/close")
- expect(page).not_to have_content '/close'
+ expect(page).to have_content '/close'
expect(page).not_to have_content 'Commands applied'
expect(issuable).to be_open
@@ -158,16 +157,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user)
project.add_guest(guest)
- sign_out(:user)
- sign_in(guest)
-
+ gitlab_sign_out
+ gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not reopen the #{issuable_type}" do
write_note("/reopen")
- expect(page).not_to have_content '/reopen'
+ expect(page).to have_content '/reopen'
expect(page).not_to have_content 'Commands applied'
expect(issuable).to be_closed
@@ -192,15 +190,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user)
project.add_guest(guest)
- sign_out(:user)
- sign_in(guest)
+ gitlab_sign_out
+ gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not reopen the #{issuable_type}" do
write_note("/title Awesome new title")
- expect(page).not_to have_content '/title'
+ expect(page).to have_content '/title'
expect(page).not_to have_content 'Commands applied'
expect(issuable.reload.title).not_to eq 'Awesome new title'
@@ -292,7 +290,7 @@ shared_examples 'issuable record that supports quick actions in its description
end
end
- describe "preview of note on #{issuable_type}" do
+ describe "preview of note on #{issuable_type}", js: true do
it 'removes quick actions from note and explains them' do
create(:user, username: 'bob')
diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb
index 5a0e7c3d099..192a2fed0a8 100644
--- a/spec/support/features/reportable_note_shared_examples.rb
+++ b/spec/support/features/reportable_note_shared_examples.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-shared_examples 'reportable note' do
+shared_examples 'reportable note' do |type|
include NotesHelper
let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") }
@@ -20,7 +20,12 @@ shared_examples 'reportable note' do
open_dropdown(dropdown)
expect(dropdown).to have_link('Report as abuse', href: abuse_report_path)
- expect(dropdown).to have_link('Delete comment', href: note_url(note, project))
+
+ if type == 'issue'
+ expect(dropdown).to have_button('Delete comment')
+ else
+ expect(dropdown).to have_link('Delete comment', href: note_url(note, project))
+ end
end
it 'Report button links to a report page' do
diff --git a/spec/support/notify_shared_examples.rb b/spec/support/notify_shared_examples.rb
index 136f92c6419..e2c23607406 100644
--- a/spec/support/notify_shared_examples.rb
+++ b/spec/support/notify_shared_examples.rb
@@ -1,9 +1,10 @@
shared_context 'gitlab email notification' do
+ set(:project) { create(:project, :repository) }
+ set(:recipient) { create(:user, email: 'recipient@example.com') }
+
let(:gitlab_sender_display_name) { Gitlab.config.gitlab.email_display_name }
let(:gitlab_sender) { Gitlab.config.gitlab.email_from }
let(:gitlab_sender_reply_to) { Gitlab.config.gitlab.email_reply_to }
- let(:recipient) { create(:user, email: 'recipient@example.com') }
- let(:project) { create(:project) }
let(:new_user_address) { 'newguy@example.com' }
before do
diff --git a/yarn.lock b/yarn.lock
index 5245666fa43..de4a9ac4487 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6307,6 +6307,10 @@ vue@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed"
+vuex@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.3.1.tgz#cde8e997c1f9957719bc7dea154f9aa691d981a6"
+
watchpack@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac"