summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml64
-rw-r--r--.gitlab/issue_templates/Feature proposal.md2
-rw-r--r--.gitlab/merge_request_templates/Security Release.md4
-rw-r--r--.rubocop_todo.yml5
-rw-r--r--CHANGELOG.md14
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--GITLAB_WORKHORSE_VERSION2
-rw-r--r--Gemfile5
-rw-r--r--Gemfile.lock18
-rw-r--r--app/assets/javascripts/api.js7
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js19
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js15
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js28
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue59
-rw-r--r--app/assets/javascripts/clusters/constants.js3
-rw-r--r--app/assets/javascripts/diffs/components/app.vue4
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue57
-rw-r--r--app/assets/javascripts/diffs/index.js51
-rw-r--r--app/assets/javascripts/diffs/store/actions.js4
-rw-r--r--app/assets/javascripts/diffs/store/getters.js37
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js3
-rw-r--r--app/assets/javascripts/ide/components/ide.vue46
-rw-r--r--app/assets/javascripts/ide/constants.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js2
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue59
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue18
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue6
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js11
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js16
-rw-r--r--app/assets/javascripts/lib/utils/grammar.js40
-rw-r--r--app/assets/javascripts/lib/utils/icon_utils.js18
-rw-r--r--app/assets/javascripts/merge_request.js1
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue149
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue77
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue329
-rw-r--r--app/assets/javascripts/monitoring/components/graph/axis.vue118
-rw-r--r--app/assets/javascripts/monitoring/components/graph/deployment.vue48
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue151
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue62
-rw-r--r--app/assets/javascripts/monitoring/components/graph/path.vue65
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_info.vue28
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_line.vue33
-rw-r--r--app/assets/javascripts/monitoring/event_hub.js3
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js86
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js10
-rw-r--r--app/assets/javascripts/monitoring/utils/date_time_formatters.js42
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js44
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js223
-rw-r--r--app/assets/javascripts/notes.js4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue17
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue20
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue40
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue12
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue22
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue18
-rw-r--r--app/assets/javascripts/notes/index.js3
-rw-r--r--app/assets/javascripts/notes/stores/actions.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js5
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue2
-rw-r--r--app/assets/javascripts/serverless/components/environment_row.vue65
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue25
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue55
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue59
-rw-r--r--app/assets/javascripts/serverless/components/url.vue38
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js11
-rw-r--r--app/assets/javascripts/task_list.js72
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue (renamed from app/assets/javascripts/ide/components/file_finder/index.vue)136
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue (renamed from app/assets/javascripts/ide/components/file_finder/item.vue)26
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue14
-rw-r--r--app/assets/stylesheets/framework/common.scss15
-rw-r--r--app/assets/stylesheets/framework/images.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss20
-rw-r--r--app/assets/stylesheets/pages/environments.scss12
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss6
-rw-r--r--app/assets/stylesheets/pages/serverless.scss3
-rw-r--r--app/controllers/concerns/issuable_actions.rb6
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/record_user_last_activity.rb27
-rw-r--r--app/controllers/concerns/send_file_upload.rb19
-rw-r--r--app/controllers/concerns/service_params.rb2
-rw-r--r--app/controllers/dashboard/application_controller.rb1
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups_controller.rb1
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb22
-rw-r--r--app/controllers/projects/issues_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/finders/issuable_finder.rb4
-rw-r--r--app/finders/issues_finder.rb1
-rw-r--r--app/finders/merge_requests_finder.rb1
-rw-r--r--app/graphql/resolvers/issues_resolver.rb4
-rw-r--r--app/helpers/auth_helper.rb7
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/markup_helper.rb6
-rw-r--r--app/helpers/notes_helper.rb1
-rw-r--r--app/helpers/profiles_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb6
-rw-r--r--app/models/application_record.rb8
-rw-r--r--app/models/ci/build.rb14
-rw-r--r--app/models/concerns/cache_markdown_field.rb27
-rw-r--r--app/models/concerns/issuable.rb45
-rw-r--r--app/models/concerns/noteable.rb15
-rw-r--r--app/models/concerns/taskable.rb8
-rw-r--r--app/models/discussion.rb6
-rw-r--r--app/models/environment.rb12
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb83
-rw-r--r--app/models/individual_note_discussion.rb8
-rw-r--r--app/models/merge_request_diff.rb116
-rw-r--r--app/models/merge_request_diff_file.rb14
-rw-r--r--app/models/programming_language.rb6
-rw-r--r--app/models/project.rb24
-rw-r--r--app/models/repository.rb3
-rw-r--r--app/models/sent_notification.rb25
-rw-r--r--app/models/ssh_host_key.rb3
-rw-r--r--app/serializers/merge_request_basic_entity.rb1
-rw-r--r--app/services/ci/create_pipeline_service.rb14
-rw-r--r--app/services/ci/pipeline_trigger_service.rb10
-rw-r--r--app/services/groups/create_service.rb6
-rw-r--r--app/services/groups/update_service.rb6
-rw-r--r--app/services/issuable/common_system_notes_service.rb2
-rw-r--r--app/services/issuable_base_service.rb59
-rw-r--r--app/services/issues/update_service.rb9
-rw-r--r--app/services/members/create_service.rb14
-rw-r--r--app/services/merge_requests/base_service.rb1
-rw-r--r--app/services/notes/build_service.rb2
-rw-r--r--app/services/notes/create_service.rb4
-rw-r--r--app/services/preview_markdown_service.rb11
-rw-r--r--app/services/projects/update_pages_configuration_service.rb34
-rw-r--r--app/services/search/global_service.rb14
-rw-r--r--app/services/task_list_toggle_service.rb71
-rw-r--r--app/uploaders/external_diff_uploader.rb23
-rw-r--r--app/views/admin/appearances/_form.html.haml168
-rw-r--r--app/views/admin/appearances/show.html.haml7
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml4
-rw-r--r--app/views/admin/application_settings/show.html.haml2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.text.haml2
-rw-r--r--app/views/events/_events.html.haml16
-rw-r--r--app/views/instance_statistics/conversational_development_index/_callout.html.haml6
-rw-r--r--app/views/instance_statistics/conversational_development_index/_card.html.haml4
-rw-r--r--app/views/instance_statistics/conversational_development_index/_no_data.html.haml6
-rw-r--r--app/views/instance_statistics/conversational_development_index/index.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml2
-rw-r--r--app/views/profiles/show.html.haml6
-rw-r--r--app/views/projects/_home_panel.html.haml9
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/blob/edit.html.haml2
-rw-r--r--app/views/projects/blob/preview.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml4
-rw-r--r--app/views/projects/issues/_form.html.haml3
-rw-r--r--app/views/projects/merge_requests/_form.html.haml3
-rw-r--r--app/views/projects/merge_requests/show.html.haml3
-rw-r--r--app/views/projects/milestones/_form.html.haml3
-rw-r--r--app/views/projects/tags/releases/edit.html.haml3
-rw-r--r--app/views/projects/wikis/_form.html.haml6
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml2
-rw-r--r--app/views/projects/wikis/show.html.haml2
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml19
-rw-r--r--app/views/shared/groups/_list.html.haml14
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/projects/_list.html.haml32
-rw-r--r--app/views/shared/snippets/_form.html.haml3
-rw-r--r--app/views/snippets/_snippets.html.haml13
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb31
-rw-r--r--changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml5
-rw-r--r--changelogs/unreleased/28500-empty-states-for-profile-page.yml5
-rw-r--r--changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml5
-rw-r--r--changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml5
-rw-r--r--changelogs/unreleased/51759-filter-by-language.yml5
-rw-r--r--changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml5
-rw-r--r--changelogs/unreleased/52568-external-mr-diffs.yml5
-rw-r--r--changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml5
-rw-r--r--changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml5
-rw-r--r--changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml5
-rw-r--r--changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml5
-rw-r--r--changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml5
-rw-r--r--changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml5
-rw-r--r--changelogs/unreleased/chore-update-js-regex.yml5
-rw-r--r--changelogs/unreleased/diff-file-finder.yml5
-rw-r--r--changelogs/unreleased/fix_jira_integration_VCS1019.yml5
-rw-r--r--changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml5
-rw-r--r--changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml5
-rw-r--r--changelogs/unreleased/introduce-environment-search-endpoint.yml5
-rw-r--r--changelogs/unreleased/issue_55744.yml5
-rw-r--r--changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml5
-rw-r--r--changelogs/unreleased/jprovazn-remove-redcarpet.yml5
-rw-r--r--changelogs/unreleased/knative-list.yml5
-rw-r--r--changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml5
-rw-r--r--changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml5
-rw-r--r--changelogs/unreleased/pl-serialize-ac-parameters.yml5
-rw-r--r--changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml5
-rw-r--r--changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml5
-rw-r--r--changelogs/unreleased/search-title.yml5
-rw-r--r--changelogs/unreleased/sh-encode-content-disposition.yml5
-rw-r--r--changelogs/unreleased/sh-fix-issue-9357.yml5
-rw-r--r--changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml5
-rw-r--r--changelogs/unreleased/update-gitaly.yml5
-rw-r--r--changelogs/unreleased/update-pages-config-only-when-changed.yml5
-rw-r--r--changelogs/unreleased/update-pages-extensionless-urls.yml5
-rw-r--r--changelogs/unreleased/update-ui-admin-appearance.yml5
-rw-r--r--changelogs/unreleased/update-workhorse-8-2-0.yml5
-rw-r--r--changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml5
-rw-r--r--config/gitlab.yml.example29
-rw-r--r--config/initializers/1_settings.rb8
-rw-r--r--config/initializers/sprockets_base_file_digest_key.rb3
-rw-r--r--config/routes/import.rb2
-rw-r--r--config/routes/project.rb1
-rw-r--r--db/migrate/20190109153125_add_merge_request_external_diffs.rb25
-rw-r--r--db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb16
-rw-r--r--db/post_migrate/20190131122559_fix_null_type_labels.rb23
-rw-r--r--db/schema.rb15
-rw-r--r--doc/administration/index.md1
-rw-r--r--doc/administration/merge_request_diffs.md154
-rw-r--r--doc/api/issues.md2
-rw-r--r--doc/api/merge_requests.md3
-rw-r--r--doc/api/projects.md2
-rw-r--r--doc/api/users.md1
-rw-r--r--doc/ci/examples/browser_performance.md2
-rw-r--r--doc/ci/examples/code_quality.md2
-rw-r--r--doc/ci/examples/container_scanning.md2
-rw-r--r--doc/ci/examples/dast.md2
-rw-r--r--doc/ci/pipelines.md2
-rw-r--r--doc/ci/quick_start/README.md2
-rw-r--r--doc/ci/yaml/README.md2
-rw-r--r--doc/development/documentation/index.md2
-rw-r--r--doc/development/file_storage.md2
-rw-r--r--doc/development/sql.md34
-rw-r--r--doc/ssh/README.md6
-rw-r--r--doc/user/gitlab_com/index.md2
-rw-r--r--doc/user/instance_statistics/user_cohorts.md1
-rw-r--r--doc/user/markdown.md25
-rw-r--r--doc/user/project/operations/index.md11
-rw-r--r--doc/user/project/pages/getting_started_part_three.md6
-rw-r--r--doc/user/project/pages/getting_started_part_two.md18
-rw-r--r--doc/user/project/pages/index.md4
-rw-r--r--doc/user/project/pages/introduction.md51
-rw-r--r--doc/workflow/repository_mirroring.md10
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/entities.rb4
-rw-r--r--lib/api/helpers.rb10
-rw-r--r--lib/api/issues.rb3
-rw-r--r--lib/api/merge_requests.rb3
-rw-r--r--lib/api/projects.rb6
-rw-r--r--lib/api/users.rb6
-rw-r--r--lib/banzai/filter/autolink_filter.rb6
-rw-r--r--lib/banzai/filter/markdown_engines/redcarpet.rb34
-rw-r--r--lib/banzai/filter/spaced_link_filter.rb2
-rw-r--r--lib/banzai/filter/syntax_highlight_filter.rb1
-rw-r--r--lib/banzai/renderer/redcarpet/html.rb17
-rw-r--r--lib/gitlab/auth/ldap/adapter.rb19
-rw-r--r--lib/gitlab/background_migration.rb15
-rw-r--r--lib/gitlab/ci/pipeline/chain/limit/activity.rb21
-rw-r--r--lib/gitlab/ci/pipeline/chain/limit/size.rb21
-rw-r--r--lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb19
-rw-r--r--lib/gitlab/content_disposition.rb54
-rw-r--r--lib/gitlab/import_export/import_export.yml6
-rw-r--r--lib/gitlab/patch/sprockets_base_file_digest_key.rb22
-rw-r--r--lib/gitlab/task_helpers.rb8
-rw-r--r--lib/gitlab/utils/merge_hash.rb2
-rw-r--r--lib/gitlab/utils/override.rb2
-rw-r--r--lib/gitlab/utils/strong_memoize.rb2
-rw-r--r--lib/tasks/gitlab/assets.rake18
-rw-r--r--lib/tasks/gitlab/backup.rake92
-rw-r--r--lib/tasks/gitlab/db.rake5
-rw-r--r--locale/gitlab.pot136
-rw-r--r--package.json7
-rw-r--r--qa/qa.rb10
-rw-r--r--qa/qa/page/admin/menu.rb9
-rw-r--r--qa/qa/page/admin/settings/component/account_and_limit.rb26
-rw-r--r--qa/qa/page/admin/settings/general.rb23
-rw-r--r--qa/qa/page/main/login.rb18
-rw-r--r--qa/qa/page/project/job/show.rb10
-rw-r--r--qa/qa/page/project/pipeline/show.rb6
-rw-r--r--qa/qa/runtime/env.rb8
-rw-r--r--qa/qa/scenario/test/integration/oauth.rb13
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb16
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb70
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb6
-rw-r--r--qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb54
-rw-r--r--qa/qa/vendor/github/page/base.rb14
-rw-r--r--qa/qa/vendor/github/page/login.rb23
-rw-r--r--qa/spec/scenario/test/integration/oauth_spec.rb9
-rwxr-xr-xscripts/clean-old-cached-assets6
-rwxr-xr-xscripts/review_apps/review-apps.sh5
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb25
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb23
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb16
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb73
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb17
-rw-r--r--spec/controllers/projects/services_controller_spec.rb10
-rw-r--r--spec/factories/project_error_tracking_settings.rb2
-rw-r--r--spec/factories/projects.rb2
-rw-r--r--spec/features/admin/admin_appearance_spec.rb12
-rw-r--r--spec/features/markdown/markdown_spec.rb27
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb32
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb63
-rw-r--r--spec/features/projects/artifacts/user_downloads_artifacts_spec.rb2
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb19
-rw-r--r--spec/features/projects/blobs/edit_spec.rb11
-rw-r--r--spec/features/projects/clusters/applications_spec.rb10
-rw-r--r--spec/features/projects/jobs_spec.rb2
-rw-r--r--spec/features/projects/serverless/functions_spec.rb9
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb14
-rw-r--r--spec/features/projects_spec.rb11
-rw-r--r--spec/features/snippets/show_spec.rb21
-rw-r--r--spec/features/task_lists_spec.rb66
-rw-r--r--spec/features/users/overview_spec.rb4
-rw-r--r--spec/finders/issues_finder_spec.rb8
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json3
-rw-r--r--spec/fixtures/markdown.md.erb2
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb20
-rw-r--r--spec/helpers/issuables_helper_spec.rb2
-rw-r--r--spec/helpers/markup_helper_spec.rb21
-rw-r--r--spec/helpers/projects_helper_spec.rb23
-rw-r--r--spec/helpers/users_helper_spec.rb2
-rw-r--r--spec/javascripts/api_spec.js16
-rw-r--r--spec/javascripts/clusters/clusters_bundle_spec.js57
-rw-r--r--spec/javascripts/clusters/components/application_row_spec.js49
-rw-r--r--spec/javascripts/diffs/components/tree_list_spec.js21
-rw-r--r--spec/javascripts/diffs/store/getters_spec.js6
-rw-r--r--spec/javascripts/ide/components/ide_spec.js68
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js194
-rw-r--r--spec/javascripts/issue_show/components/description_spec.js17
-rw-r--r--spec/javascripts/issue_show/mock_data.js2
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js15
-rw-r--r--spec/javascripts/lib/utils/grammar_spec.js35
-rw-r--r--spec/javascripts/lib/utils/icon_utils_spec.js67
-rw-r--r--spec/javascripts/merge_request_spec.js17
-rw-r--r--spec/javascripts/monitoring/dashboard_spec.js12
-rw-r--r--spec/javascripts/monitoring/graph/axis_spec.js65
-rw-r--r--spec/javascripts/monitoring/graph/deployment_spec.js53
-rw-r--r--spec/javascripts/monitoring/graph/flag_spec.js133
-rw-r--r--spec/javascripts/monitoring/graph/legend_spec.js44
-rw-r--r--spec/javascripts/monitoring/graph/track_info_spec.js44
-rw-r--r--spec/javascripts/monitoring/graph/track_line_spec.js52
-rw-r--r--spec/javascripts/monitoring/graph_path_spec.js56
-rw-r--r--spec/javascripts/monitoring/graph_spec.js127
-rw-r--r--spec/javascripts/monitoring/utils/multiple_time_series_spec.js22
-rw-r--r--spec/javascripts/notes/components/discussion_reply_placeholder_spec.js34
-rw-r--r--spec/javascripts/notes/components/note_actions/reply_button_spec.js46
-rw-r--r--spec/javascripts/notes/components/note_actions_spec.js149
-rw-r--r--spec/javascripts/notes/components/note_app_spec.js167
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js31
-rw-r--r--spec/javascripts/notes/components/noteable_note_spec.js93
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js14
-rw-r--r--spec/javascripts/notes/stores/mutation_spec.js23
-rw-r--r--spec/javascripts/notes_spec.js19
-rw-r--r--spec/javascripts/serverless/components/environment_row_spec.js81
-rw-r--r--spec/javascripts/serverless/components/function_row_spec.js33
-rw-r--r--spec/javascripts/serverless/components/functions_spec.js68
-rw-r--r--spec/javascripts/serverless/components/url_spec.js28
-rw-r--r--spec/javascripts/serverless/mock_data.js79
-rw-r--r--spec/javascripts/serverless/stores/serverless_store_spec.js36
-rw-r--r--spec/javascripts/task_list_spec.js156
-rw-r--r--spec/javascripts/vue_shared/components/file_finder/index_spec.js (renamed from spec/javascripts/ide/components/file_finder/index_spec.js)200
-rw-r--r--spec/javascripts/vue_shared/components/file_finder/item_spec.js (renamed from spec/javascripts/ide/components/file_finder/item_spec.js)6
-rw-r--r--spec/lib/api/helpers_spec.rb2
-rw-r--r--spec/lib/banzai/filter/autolink_filter_spec.rb7
-rw-r--r--spec/lib/banzai/filter/markdown_filter_spec.rb36
-rw-r--r--spec/lib/banzai/filter/spaced_link_filter_spec.rb5
-rw-r--r--spec/lib/gitlab/background_migration_spec.rb32
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb26
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml3
-rw-r--r--spec/migrations/fix_null_type_labels_spec.rb36
-rw-r--r--spec/models/application_record_spec.rb10
-rw-r--r--spec/models/ci/build_spec.rb20
-rw-r--r--spec/models/concerns/cache_markdown_field_spec.rb187
-rw-r--r--spec/models/concerns/issuable_spec.rb72
-rw-r--r--spec/models/environment_spec.rb70
-rw-r--r--spec/models/error_tracking/project_error_tracking_setting_spec.rb176
-rw-r--r--spec/models/merge_request_diff_spec.rb189
-rw-r--r--spec/models/repository_spec.rb21
-rw-r--r--spec/models/sent_notification_spec.rb34
-rw-r--r--spec/models/ssh_host_key_spec.rb29
-rw-r--r--spec/requests/api/files_spec.rb2
-rw-r--r--spec/requests/api/issues_spec.rb12
-rw-r--r--spec/requests/api/jobs_spec.rb4
-rw-r--r--spec/requests/api/merge_requests_spec.rb12
-rw-r--r--spec/requests/api/projects_spec.rb60
-rw-r--r--spec/requests/api/releases_spec.rb25
-rw-r--r--spec/requests/api/runner_spec.rb2
-rw-r--r--spec/requests/api/users_spec.rb2
-rw-r--r--spec/requests/user_activity_spec.rb114
-rw-r--r--spec/services/issues/update_service_spec.rb70
-rw-r--r--spec/services/members/create_service_spec.rb9
-rw-r--r--spec/services/merge_requests/create_service_spec.rb18
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb8
-rw-r--r--spec/services/notes/build_service_spec.rb40
-rw-r--r--spec/services/notes/create_service_spec.rb37
-rw-r--r--spec/services/preview_markdown_service_spec.rb19
-rw-r--r--spec/services/projects/create_service_spec.rb6
-rw-r--r--spec/services/projects/update_pages_configuration_service_spec.rb32
-rw-r--r--spec/services/task_list_toggle_service_spec.rb85
-rw-r--r--spec/support/helpers/stub_configuration.rb4
-rw-r--r--spec/support/helpers/stub_object_storage.rb7
-rw-r--r--spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb10
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb38
-rw-r--r--spec/uploaders/external_diff_uploader_spec.rb67
-rw-r--r--spec/validators/js_regex_validator_spec.rb2
-rw-r--r--spec/workers/mail_scheduler/notification_service_worker_spec.rb49
-rw-r--r--yarn.lock31
416 files changed, 6328 insertions, 3970 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 16c56747711..b93a79de994 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -120,9 +120,8 @@ stages:
variables: &single-script-job-variables
GIT_STRATEGY: none
before_script:
- # We need to download the script rather than clone the repo since the
- # package-and-qa job will not be able to run when the branch gets
- # deleted (when merging the MR).
+ # We don't clone the repo by using GIT_STRATEGY: none and only download the
+ # single script we need here so it's much faster than cloning.
- export SCRIPT_NAME="${SCRIPT_NAME:-$CI_JOB_NAME}"
- apk add --update openssl
- wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/$SCRIPT_NAME
@@ -228,20 +227,21 @@ stages:
# Trigger a package build in omnibus-gitlab repository
#
package-and-qa:
- <<: *single-script-job
+ image: ruby:2.5-alpine
+ stage: test
+ before_script: []
+ dependencies: []
+ cache: {}
variables:
- <<: *single-script-job-variables
+ GIT_DEPTH: "1"
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}"
- SCRIPT_NAME: trigger-build
retry: 0
script:
- - gem install gitlab --no-document
- apk add --update openssl curl jq
- - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/review_apps/review-apps.sh
- - chmod 755 review-apps.sh
- - source ./review-apps.sh
+ - gem install gitlab --no-document
+ - source ./scripts/review_apps/review-apps.sh
- wait_for_job_to_be_done "gitlab:assets:compile"
- - ./$SCRIPT_NAME omnibus
+ - ./scripts/trigger-build omnibus
when: manual
only:
- //@gitlab-org/gitlab-ce
@@ -386,20 +386,25 @@ flaky-examples-check:
- scripts/merge-reports ${NEW_FLAKY_SPECS_REPORT} rspec_flaky/new_*_*.json
- scripts/detect-new-flaky-examples $NEW_FLAKY_SPECS_REPORT
+.assets-compile-cache: &assets-compile-cache
+ cache:
+ key: "assets-compile:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v3"
+ paths:
+ - vendor/ruby/
+ - .yarn-cache/
+ - tmp/cache/assets/sprockets
+
compile-assets:
<<: *dedicated-runner
<<: *except-docs
<<: *use-pg
stage: prepare
- cache:
- <<: *default-cache
script:
- node --version
- - date
- yarn install --frozen-lockfile --cache-folder .yarn-cache
- - date
- free -m
- bundle exec rake gitlab:assets:compile
+ - scripts/clean-old-cached-assets
variables:
# we override the max_old_space_size to prevent OOM errors
NODE_OPTIONS: --max_old_space_size=3584
@@ -408,6 +413,7 @@ compile-assets:
paths:
- node_modules
- public/assets
+ <<: *assets-compile-cache
setup-test-env:
<<: *dedicated-runner
@@ -628,7 +634,9 @@ gitlab:setup-mysql:
gitlab:assets:compile:
<<: *dedicated-no-docs-pull-cache-job
image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.18-chrome-71.0-node-8.x-yarn-1.12-graphicsmagick-1.3.29-docker-18.06.1
- dependencies: []
+ dependencies:
+ - setup-test-env
+ - compile-assets
services:
- docker:stable-dind
variables:
@@ -642,18 +650,19 @@ gitlab:assets:compile:
DOCKER_DRIVER: overlay2
DOCKER_HOST: tcp://docker:2375
script:
- - date
+ - node --version
- yarn install --frozen-lockfile --production --cache-folder .yarn-cache
- - date
- free -m
- bundle exec rake gitlab:assets:compile
- - scripts/build_assets_image
+ - time scripts/build_assets_image
+ - scripts/clean-old-cached-assets
artifacts:
name: webpack-report
expire_in: 31d
paths:
- webpack-report/
- public/assets/
+ <<: *assets-compile-cache
only:
- //@gitlab-org/gitlab-ce
- //@gitlab-org/gitlab-ee
@@ -951,20 +960,21 @@ no_ee_check:
# GitLab Review apps
review-build-cng:
- <<: *single-script-job
<<: *review-only
+ image: ruby:2.5-alpine
+ stage: test
+ before_script: []
+ dependencies: []
+ cache: {}
variables:
- <<: *single-script-job-variables
- SCRIPT_NAME: trigger-build
+ GIT_DEPTH: "1"
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}"
script:
- - gem install gitlab --no-document
- apk add --update openssl curl jq
- - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/review_apps/review-apps.sh
- - chmod 755 review-apps.sh
- - source ./review-apps.sh
+ - gem install gitlab --no-document
+ - source ./scripts/review_apps/review-apps.sh
- wait_for_job_to_be_done "gitlab:assets:compile"
- - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./$SCRIPT_NAME cng
+ - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./scripts/trigger-build cng
review-deploy:
<<: *review-base
diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md
index 0b22c7bc26b..1bb8d33ff63 100644
--- a/.gitlab/issue_templates/Feature proposal.md
+++ b/.gitlab/issue_templates/Feature proposal.md
@@ -39,7 +39,7 @@ Existing personas are: (copy relevant personas out of this comment, and delete a
### What does success look like, and how can we measure that?
-<!--- Define both the success metrics and acceptance criteria. Note thet success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this -->
+<!--- Define both the success metrics and acceptance criteria. Note that success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this -->
### Links / references
diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md
index d72b4eb1cb6..9a0979f27a7 100644
--- a/.gitlab/merge_request_templates/Security Release.md
+++ b/.gitlab/merge_request_templates/Security Release.md
@@ -9,7 +9,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla
<!-- Mention the issue(s) this MR is related to -->
-## Author's checklist
+## Developer checklist
- [ ] Link to the developer security workflow issue on `dev.gitlab.org`
- [ ] MR targets `master` or `security-X-Y` for backports
@@ -20,7 +20,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla
- [ ] Add a link to an EE MR if required
- [ ] Assign to a reviewer
-## Reviewers checklist
+## Reviewer checklist
- [ ] Correct milestone is applied and the title is matching across all backports
- [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index c42d11a860e..77ad4753c84 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -80,11 +80,6 @@ Lint/InterpolationCheck:
Lint/MissingCopEnableDirective:
Enabled: false
-# Offense count: 1
-Lint/ReturnInVoidContext:
- Exclude:
- - 'app/models/project.rb'
-
# Offense count: 9
Lint/UriEscapeUnescape:
Exclude:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4985c607d57..e220d61b316 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 11.7.5 (2019-02-06)
+
+### Fixed (8 changes)
+
+- Fix import handling errors in Bitbucket Server importer. !24499
+- Adjusts suggestions unable to be applied. !24603
+- Fix 500 errors with legacy appearance logos. !24615
+- Fix form functionality for edit tag page. !24645
+- Update Workhorse to v8.0.2. !24870
+- Downcase aliased OAuth2 callback providers. !24877
+- Fix Detect Host Keys not working. !24884
+- Changed external wiki query method to prevent attribute caching. !24907
+
+
## 11.7.2 (2019-01-29)
### Fixed (1 change)
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 63e799cf451..092afa15df4 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-1.14.1
+1.17.0
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index 88c5fb891dc..bc80560fad6 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-1.4.0
+1.5.0
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index 0e79152459e..fbb9ea12de3 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-8.1.1
+8.2.0
diff --git a/Gemfile b/Gemfile
index a5f3afcaa55..a2527b17b70 100644
--- a/Gemfile
+++ b/Gemfile
@@ -113,10 +113,9 @@ gem 'seed-fu', '~> 2.3.7'
# Markdown and HTML processing
gem 'html-pipeline', '~> 2.8'
-gem 'deckar01-task_list', '2.0.1'
+gem 'deckar01-task_list', '2.2.0'
gem 'gitlab-markup', '~> 1.6.5'
gem 'github-markup', '~> 1.7.0', require: 'github/markup'
-gem 'redcarpet', '~> 3.4'
gem 'commonmarker', '~> 0.17'
gem 'RedCloth', '~> 4.3.2'
gem 'rdoc', '~> 6.0'
@@ -188,7 +187,7 @@ gem 're2', '~> 1.1.1'
gem 'version_sorter', '~> 2.1.0'
# Export Ruby Regex to Javascript
-gem 'js_regex', '~> 2.2.1'
+gem 'js_regex', '~> 3.1'
# User agent parsing
gem 'device_detector'
diff --git a/Gemfile.lock b/Gemfile.lock
index 1c28176ac62..f661da41507 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -113,6 +113,7 @@ GEM
activesupport (>= 4.0.0)
mime-types (>= 1.16)
cause (0.1)
+ character_set (1.1.2)
charlock_holmes (0.7.6)
childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11)
@@ -143,7 +144,7 @@ GEM
database_cleaner (1.7.0)
debug_inspector (0.0.3)
debugger-ruby_core_source (1.3.8)
- deckar01-task_list (2.0.1)
+ deckar01-task_list (2.2.0)
html-pipeline
declarative (0.0.10)
declarative-option (0.1.0)
@@ -400,8 +401,10 @@ GEM
multipart-post
oauth (~> 0.5, >= 0.5.0)
jquery-atwho-rails (1.3.2)
- js_regex (2.2.1)
- regexp_parser (>= 0.4.11, <= 0.5.0)
+ js_regex (3.1.1)
+ character_set (~> 1.1)
+ regexp_parser (~> 1.1)
+ regexp_property_values (~> 0.3)
json (1.8.6)
json-jwt (1.9.4)
activesupport
@@ -682,7 +685,6 @@ GEM
recaptcha (3.0.0)
json
recursive-open-struct (1.1.0)
- redcarpet (3.4.0)
redis (3.3.5)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
@@ -702,7 +704,8 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.6.0)
redis (>= 2.2, < 5)
- regexp_parser (0.5.0)
+ regexp_parser (1.3.0)
+ regexp_property_values (0.3.4)
representable (3.0.4)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
@@ -982,7 +985,7 @@ DEPENDENCIES
connection_pool (~> 2.0)
creole (~> 0.5.0)
database_cleaner (~> 1.7.0)
- deckar01-task_list (= 2.0.1)
+ deckar01-task_list (= 2.2.0)
device_detector
devise (~> 4.4)
devise-two-factor (~> 3.0.0)
@@ -1049,7 +1052,7 @@ DEPENDENCIES
jaeger-client (~> 0.10.0)
jira-ruby (~> 1.4)
jquery-atwho-rails (~> 1.3.2)
- js_regex (~> 2.2.1)
+ js_regex (~> 3.1)
json-schema (~> 2.8.0)
jwt (~> 2.1.0)
kaminari (~> 1.0)
@@ -1118,7 +1121,6 @@ DEPENDENCIES
rdoc (~> 6.0)
re2 (~> 1.1.1)
recaptcha (~> 3.0)
- redcarpet (~> 3.4)
redis (~> 3.2)
redis-namespace (~> 1.6.0)
redis-rails (~> 5.0.2)
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index d1396b6c4bc..85eb08cc97d 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
+ groupMembersPath: '/api/:version/groups/:id/members',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
@@ -40,6 +41,12 @@ const Api = {
});
},
+ groupMembers(id) {
+ const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
// Return groups list. Filtered by query
groups(query, options, callback = $.noop) {
const url = Api.buildUrl(Api.groupsPath);
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 56293d5f96f..d1d75658181 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,11 +1,10 @@
-import installCustomElements from 'document-register-element';
+import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support';
-installCustomElements(window);
+class GlEmoji extends HTMLElement {
+ constructor() {
+ super();
-export default function installGlEmojiElement() {
- const GlEmojiElementProto = Object.create(HTMLElement.prototype);
- GlEmojiElementProto.createdCallback = function createdCallback() {
const emojiUnicode = this.textContent.trim();
const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset;
@@ -43,9 +42,11 @@ export default function installGlEmojiElement() {
});
}
}
- };
+ }
+}
- document.registerElement('gl-emoji', {
- prototype: GlEmojiElementProto,
- });
+export default function installGlEmojiElement() {
+ if (!customElements.get('gl-emoji')) {
+ customElements.define('gl-emoji', GlEmoji);
+ }
}
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 35f1bb6b080..7adccbb062f 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -28,16 +28,13 @@ MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function($form) {
var mdText;
- var markdownVersion;
- var url;
var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
- markdownVersion = $form.attr('data-markdown-version');
- url = this.versionedPreviewPath(preview.data('url'), markdownVersion);
if (mdText.trim().length === 0) {
preview.text(this.emptyMessage);
@@ -67,16 +64,6 @@ MarkdownPreview.prototype.showPreview = function($form) {
}
};
-MarkdownPreview.prototype.versionedPreviewPath = function(markdownPreviewPath, markdownVersion) {
- if (typeof markdownVersion === 'undefined') {
- return markdownPreviewPath;
- }
-
- return `${markdownPreviewPath}${
- markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
- }markdown_version=${markdownVersion}`;
-};
-
MarkdownPreview.prototype.fetchMarkdownPreview = function(text, url, success) {
if (!url) {
return;
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index b1f992c03ff..fc4779632f9 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -6,7 +6,7 @@ import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
-import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants';
+import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
@@ -231,22 +231,18 @@ export default class Clusters {
installApplication(data) {
const appId = data.id;
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING);
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED);
this.store.updateAppProperty(appId, 'requestReason', null);
-
- this.service
- .installApplication(appId, data.params)
- .then(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS);
- })
- .catch(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
- this.store.updateAppProperty(
- appId,
- 'requestReason',
- s__('ClusterIntegration|Request to begin installing failed'),
- );
- });
+ this.store.updateAppProperty(appId, 'statusReason', null);
+
+ this.service.installApplication(appId, data.params).catch(() => {
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
+ this.store.updateAppProperty(
+ appId,
+ 'requestReason',
+ s__('ClusterIntegration|Request to begin installing failed'),
+ );
+ });
}
destroy() {
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index d4354dcfebd..3c3ce1dec56 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -4,12 +4,7 @@ import { s__, sprintf } from '../../locale';
import eventHub from '../event_hub';
import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
-import {
- APPLICATION_STATUS,
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
-} from '../constants';
+import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '../constants';
export default {
components: {
@@ -72,6 +67,13 @@ export default {
isKnownStatus() {
return Object.values(APPLICATION_STATUS).includes(this.status);
},
+ isInstalling() {
+ return (
+ this.status === APPLICATION_STATUS.SCHEDULED ||
+ this.status === APPLICATION_STATUS.INSTALLING ||
+ (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled)
+ );
+ },
isInstalled() {
return (
this.status === APPLICATION_STATUS.INSTALLED ||
@@ -79,6 +81,18 @@ export default {
this.status === APPLICATION_STATUS.UPDATING
);
},
+ canInstall() {
+ if (this.isInstalling) {
+ return false;
+ }
+
+ return (
+ this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
+ this.status === APPLICATION_STATUS.INSTALLABLE ||
+ this.status === APPLICATION_STATUS.ERROR ||
+ this.isUnknownStatus
+ );
+ },
hasLogo() {
return !!this.logoUrl;
},
@@ -90,12 +104,7 @@ export default {
return `js-cluster-application-row-${this.id}`;
},
installButtonLoading() {
- return (
- !this.status ||
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING ||
- this.requestStatus === REQUEST_LOADING
- );
+ return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling;
},
installButtonDisabled() {
// Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
@@ -104,30 +113,17 @@ export default {
return (
((this.status !== APPLICATION_STATUS.INSTALLABLE &&
this.status !== APPLICATION_STATUS.ERROR) ||
- this.requestStatus === REQUEST_LOADING ||
- this.requestStatus === REQUEST_SUCCESS) &&
+ this.isInstalling) &&
this.isKnownStatus
);
},
installButtonLabel() {
let label;
- if (
- this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
- this.status === APPLICATION_STATUS.INSTALLABLE ||
- this.status === APPLICATION_STATUS.ERROR ||
- this.isUnknownStatus
- ) {
+ if (this.canInstall) {
label = s__('ClusterIntegration|Install');
- } else if (
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING
- ) {
+ } else if (this.isInstalling) {
label = s__('ClusterIntegration|Installing');
- } else if (
- this.status === APPLICATION_STATUS.INSTALLED ||
- this.status === APPLICATION_STATUS.UPDATED ||
- this.status === APPLICATION_STATUS.UPDATING
- ) {
+ } else if (this.isInstalled) {
label = s__('ClusterIntegration|Installed');
}
@@ -140,7 +136,10 @@ export default {
return s__('ClusterIntegration|Manage');
},
hasError() {
- return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE;
+ return (
+ !this.isInstalling &&
+ (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE)
+ );
},
generalErrorDescription() {
return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), {
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index e31afadf186..360511e8882 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -18,8 +18,7 @@ export const APPLICATION_STATUS = {
};
// These are only used client-side
-export const REQUEST_LOADING = 'request-loading';
-export const REQUEST_SUCCESS = 'request-success';
+export const REQUEST_SUBMITTED = 'request-submitted';
export const REQUEST_FAILURE = 'request-failure';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index d4c1b07093d..f0ce2579ee7 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -129,6 +129,10 @@ export default {
created() {
this.adjustView();
eventHub.$once('fetchedNotesData', this.setDiscussions);
+ eventHub.$once('fetchDiffData', this.fetchData);
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchDiffData', this.fetchData);
},
methods: {
...mapActions(['startTaskList']),
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 0b3def3d29d..a0f09932593 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -13,39 +13,17 @@ export default {
Icon,
FileRow,
},
- data() {
- return {
- search: '',
- };
- },
computed: {
...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']),
...mapGetters('diffs', ['allBlobs', 'diffFilesLength']),
filteredTreeList() {
- const search = this.search.toLowerCase().trim();
-
- if (search === '') return this.renderTreeList ? this.tree : this.allBlobs;
-
- return this.allBlobs.reduce((acc, folder) => {
- const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
-
- if (tree.length) {
- return acc.concat({
- ...folder,
- tree,
- });
- }
-
- return acc;
- }, []);
+ return this.renderTreeList ? this.tree : this.allBlobs;
},
},
methods: {
- ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
- clearSearch() {
- this.search = '';
- },
+ ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']),
},
+ shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '&#8984;' : 'Ctrl'}+P`,
FileRowStats,
};
</script>
@@ -55,21 +33,17 @@ export default {
<div class="append-bottom-8 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<icon name="search" class="position-absolute tree-list-icon" />
- <input
- v-model="search"
- :placeholder="s__('MergeRequest|Filter files')"
- type="search"
- class="form-control"
- />
<button
- v-show="search"
- :aria-label="__('Clear search')"
type="button"
- class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
- @click="clearSearch"
+ class="form-control text-left text-secondary"
+ @click="toggleFileFinder(true)"
>
- <icon name="close" />
+ {{ s__('MergeRequest|Search files') }}
</button>
+ <span
+ class="position-absolute text-secondary diff-tree-search-shortcut"
+ v-html="$options.shortcutKeyCharacter"
+ ></span>
</div>
</div>
<div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
@@ -104,4 +78,15 @@ export default {
.tree-list-blobs .file-row-name {
margin-left: 12px;
}
+
+.diff-tree-search-shortcut {
+ top: 50%;
+ right: 10px;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.tree-list-icon {
+ pointer-events: none;
+}
</style>
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 094e5cdea9c..63954d9d412 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,11 +1,60 @@
import Vue from 'vue';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
+import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
import { TREE_LIST_STORAGE_KEY } from './constants';
export default function initDiffsApp(store) {
+ const fileFinderEl = document.getElementById('js-diff-file-finder');
+
+ if (fileFinderEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: fileFinderEl,
+ store,
+ computed: {
+ ...mapState('diffs', ['fileFinderVisible', 'isLoading']),
+ ...mapGetters('diffs', ['flatBlobsList']),
+ },
+ watch: {
+ fileFinderVisible(newVal, oldVal) {
+ if (newVal && !oldVal && !this.flatBlobsList.length) {
+ eventHub.$emit('fetchDiffData');
+ }
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
+ openFile(file) {
+ window.mrTabs.tabShown('diffs');
+ this.scrollToFile(file.path);
+ },
+ },
+ render(createElement) {
+ return createElement(FindFile, {
+ props: {
+ files: this.flatBlobsList,
+ visible: this.fileFinderVisible,
+ loading: this.isLoading,
+ showDiffStats: true,
+ clearSearchOnClose: false,
+ },
+ on: {
+ toggle: this.toggleFileFinder,
+ click: this.openFile,
+ },
+ class: ['diff-file-finder'],
+ style: {
+ display: this.fileFinderVisible ? '' : 'none',
+ },
+ });
+ },
+ });
+ }
+
return new Vue({
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 2c5019fb652..7fb66ce433b 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -296,5 +296,9 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
}
};
+export const toggleFileFinder = ({ commit }, visible) => {
+ commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 86c0c7190f9..0e1ad654a2b 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -74,24 +74,25 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash);
-export const allBlobs = state =>
- Object.values(state.treeEntries)
- .filter(f => f.type === 'blob')
- .reduce((acc, file) => {
- const { parentPath } = file;
-
- if (parentPath && !acc.some(f => f.path === parentPath)) {
- acc.push({
- path: parentPath,
- isHeader: true,
- tree: [],
- });
- }
-
- acc.find(f => f.path === parentPath).tree.push(file);
-
- return acc;
- }, []);
+export const flatBlobsList = state =>
+ Object.values(state.treeEntries).filter(f => f.type === 'blob');
+
+export const allBlobs = (state, getters) =>
+ getters.flatBlobsList.reduce((acc, file) => {
+ const { parentPath } = file;
+
+ if (parentPath && !acc.some(f => f.path === parentPath)) {
+ acc.push({
+ path: parentPath,
+ isHeader: true,
+ tree: [],
+ });
+ }
+
+ acc.find(f => f.path === parentPath).tree.push(file);
+
+ return acc;
+ }, []);
export const diffFilesLength = state => state.diffFiles.length;
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 05b4c552f6e..6ee33d9fc6d 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -29,4 +29,5 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: true,
+ fileFinderVisible: false,
});
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index e760b4d1079..71ad108ce88 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -22,3 +22,4 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
export const SET_TREE_DATA = 'SET_TREE_DATA';
export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
+export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 4aeb393b29b..7bbafe66199 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -244,4 +244,7 @@ export default {
[types.SET_SHOW_WHITESPACE](state, showWhitespace) {
state.showWhitespace = showWhitespace;
},
+ [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) {
+ state.fileFinderVisible = visible;
+ },
};
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index caec8779cac..9894ebb0624 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,20 +1,17 @@
<script>
import Vue from 'vue';
-import Mousetrap from 'mousetrap';
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
-import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
-const originalStopCallback = Mousetrap.stopCallback;
-
export default {
components: {
NewModal,
@@ -42,21 +39,18 @@ export default {
'emptyStateSvgPath',
'currentProjectId',
'errorMessage',
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'activeFile',
+ 'hasChanges',
+ 'someUncommittedChanges',
+ 'isCommitModeActive',
+ 'allBlobs',
]),
- ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
-
- Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
- if (e.preventDefault) {
- e.preventDefault();
- }
-
- this.toggleFileFinder(!this.fileFindVisible);
- });
-
- Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
},
methods: {
...mapActions(['toggleFileFinder']),
@@ -70,17 +64,8 @@ export default {
});
return returnValue;
},
- mousetrapStopCallback(e, el, combo) {
- if (
- (combo === 't' && el.classList.contains('dropdown-input-field')) ||
- el.classList.contains('inputarea')
- ) {
- return true;
- } else if (combo === 'command+p' || combo === 'ctrl+p') {
- return false;
- }
-
- return originalStopCallback(e, el, combo);
+ openFile(file) {
+ this.$router.push(`/project${file.url}`);
},
},
};
@@ -90,7 +75,14 @@ export default {
<article class="ide position-relative d-flex flex-column align-items-stretch">
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
- <find-file v-show="fileFindVisible" />
+ <find-file
+ v-show="fileFindVisible"
+ :files="allBlobs"
+ :visible="fileFindVisible"
+ :loading="loading"
+ @toggle="toggleFileFinder"
+ @click="openFile"
+ />
<ide-sidebar />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 09245ed0296..804ebae4555 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -1,8 +1,3 @@
-// Fuzzy file finder
-export const MAX_FILE_FINDER_RESULTS = 40;
-export const FILE_FINDER_ROW_HEIGHT = 55;
-export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
-
export const MAX_WINDOW_HEIGHT_COMPACT = 750;
// Commit message textarea
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index d97a950a8b2..24c2f71ae2b 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -202,7 +202,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch(
'setErrorMessage',
{
- text: __('An error accured whilst committing your changes.'),
+ text: __('An error occurred whilst committing your changes.'),
action: () =>
dispatch('commitChanges').then(() =>
dispatch('setErrorMessage', null, { root: true }),
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index cd569eb3045..bd757a76ee7 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,5 +1,7 @@
<script>
import Visibility from 'visibilityjs';
+import { __, s__, sprintf } from '~/locale';
+import createFlash from '~/flash';
import { visitUrl } from '../../lib/utils/url_utility';
import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
@@ -10,7 +12,6 @@ import descriptionComponent from './description.vue';
import editedComponent from './edited.vue';
import formComponent from './form.vue';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
-import { __ } from '~/locale';
export default {
components: {
@@ -107,11 +108,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
projectPath: {
type: String,
required: true,
@@ -130,6 +126,11 @@ export default {
required: false,
default: true,
},
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
const store = new Store({
@@ -141,6 +142,7 @@ export default {
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
taskStatus: this.initialTaskStatus,
+ lock_version: this.lockVersion,
});
return {
@@ -161,6 +163,9 @@ export default {
const titleChanged = this.initialTitleText !== this.store.formState.title;
return descriptionChanged || titleChanged;
},
+ defaultErrorMessage() {
+ return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
+ },
},
created() {
this.service = new Service(this.endpoint);
@@ -207,6 +212,17 @@ export default {
}
return undefined;
},
+ updateStoreState() {
+ return this.service
+ .getData()
+ .then(res => res.data)
+ .then(data => {
+ this.store.updateState(data);
+ })
+ .catch(() => {
+ createFlash(this.defaultErrorMessage);
+ });
+ },
openForm() {
if (!this.showForm) {
@@ -214,6 +230,7 @@ export default {
this.store.setFormState({
title: this.state.titleText,
description: this.state.descriptionText,
+ lock_version: this.state.lock_version,
lockedWarningVisible: false,
updateLoading: false,
});
@@ -232,20 +249,24 @@ export default {
if (window.location.pathname !== data.web_url) {
visitUrl(data.web_url);
}
-
- return this.service.getData();
})
- .then(res => res.data)
- .then(data => {
- this.store.updateState(data);
+ .then(this.updateStoreState)
+ .then(() => {
eventHub.$emit('close.form');
})
- .catch(error => {
- if (error && error.name === 'SpamError') {
+ .catch((error = {}) => {
+ const { name, response = {} } = error;
+
+ if (name === 'SpamError') {
this.openRecaptcha();
} else {
- eventHub.$emit('close.form');
- window.Flash(`Error updating ${this.issuableType}`);
+ let errMsg = this.defaultErrorMessage;
+
+ if (response.data && response.data.errors) {
+ errMsg += `. ${response.data.errors.join(' ')}`;
+ }
+
+ createFlash(errMsg);
}
});
},
@@ -269,8 +290,9 @@ export default {
visitUrl(data.web_url);
})
.catch(() => {
- eventHub.$emit('close.form');
- window.Flash(`Error deleting ${this.issuableType}`);
+ createFlash(
+ sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
+ );
});
},
},
@@ -286,7 +308,6 @@ export default {
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
- :markdown-version="markdownVersion"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
@@ -314,6 +335,8 @@ export default {
:task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
+ :lock-version="state.lock_version"
+ @taskListUpdateFailed="updateStoreState"
/>
<edited-component
v-if="hasUpdated"
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 5ca88d75063..e664269b199 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -1,5 +1,6 @@
<script>
import $ from 'jquery';
+import { __ } from '~/locale';
import animateMixin from '../mixins/animate';
import TaskList from '../../task_list';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
@@ -35,6 +36,11 @@ export default {
required: false,
default: null,
},
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -67,8 +73,10 @@ export default {
new TaskList({
dataType: this.issuableType,
fieldName: 'description',
+ lockVersion: this.lockVersion,
selector: '.detail-page-description',
onSuccess: this.taskListUpdateSuccess.bind(this),
+ onError: this.taskListUpdateError.bind(this),
});
}
},
@@ -82,6 +90,16 @@ export default {
}
},
+ taskListUpdateError() {
+ window.Flash(
+ __(
+ 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.',
+ ),
+ );
+
+ this.$emit('taskListUpdateFailed');
+ },
+
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 90258c0e044..299130e56ae 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -20,11 +20,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
canAttachFile: {
type: Boolean,
required: false,
@@ -48,7 +43,6 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 45b60bc3392..eade31f1d14 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -39,11 +39,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
projectPath: {
type: String,
required: true,
@@ -101,7 +96,6 @@ export default {
:form-state="formState"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
/>
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 32044d6da25..3c17e73ccec 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,3 +1,5 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
export default class Store {
constructor(initialState) {
this.state = initialState;
@@ -6,6 +8,7 @@ export default class Store {
description: '',
lockedWarningVisible: false,
updateLoading: false,
+ lock_version: 0,
};
}
@@ -14,14 +17,10 @@ export default class Store {
this.formState.lockedWarningVisible = true;
}
+ Object.assign(this.state, convertObjectPropsToCamelCase(data));
this.state.titleHtml = data.title;
- this.state.titleText = data.title_text;
this.state.descriptionHtml = data.description;
- this.state.descriptionText = data.description_text;
- this.state.taskStatus = data.task_status;
- this.state.updatedAt = data.updated_at;
- this.state.updatedByName = data.updated_by_name;
- this.state.updatedByPath = data.updated_by_path;
+ this.state.lock_version = data.lock_version;
}
stateShouldUpdate(data) {
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 91332c21b52..c5076d65ff9 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -39,7 +39,9 @@ export default {
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
<span class="font-weight-bold">{{ __('Pipeline') }}</span>
- <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a>
+ <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
+ >#{{ pipeline.id }}</a
+ >
<template v-if="hasRef">
{{ __('from') }}
<a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index ae8b4b4d635..0ceff10a02a 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -221,6 +221,22 @@ export const scrollToElement = element => {
};
/**
+ * Returns a function that can only be invoked once between
+ * each browser screen repaint.
+ * @param {Function} fn
+ */
+export const debounceByAnimationFrame = fn => {
+ let requestId;
+
+ return function debounced(...args) {
+ if (requestId) {
+ window.cancelAnimationFrame(requestId);
+ }
+ requestId = window.requestAnimationFrame(() => fn.apply(this, args));
+ };
+};
+
+/**
this will take in the `name` of the param you want to parse in the url
if the name does not exist this function will return `null`
otherwise it will return the value of the param key provided
diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js
new file mode 100644
index 00000000000..18f9e2ed846
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/grammar.js
@@ -0,0 +1,40 @@
+import { sprintf, s__ } from '~/locale';
+
+/**
+ * Combines each given item into a noun series sentence fragment. It does this
+ * in a way that supports i18n by giving context and punctuation to the locale
+ * functions.
+ *
+ * **Examples:**
+ *
+ * - `["A", "B"] => "A and B"`
+ * - `["A", "B", "C"] => "A, B, and C"`
+ *
+ * **Why only nouns?**
+ *
+ * Some languages need a bit more context to translate other series.
+ *
+ * @param {String[]} items
+ */
+export const toNounSeriesText = items => {
+ if (items.length === 0) {
+ return '';
+ } else if (items.length === 1) {
+ return items[0];
+ } else if (items.length === 2) {
+ return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), {
+ firstItem: items[0],
+ lastItem: items[1],
+ });
+ }
+
+ return items.reduce((item, nextItem, idx) =>
+ idx === items.length - 1
+ ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem })
+ : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }),
+ );
+};
+
+export default {
+ toNounSeriesText,
+};
diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js
new file mode 100644
index 00000000000..7b8dd9bbef7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/icon_utils.js
@@ -0,0 +1,18 @@
+/* eslint-disable import/prefer-default-export */
+
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * Retrieve SVG icon path content from gitlab/svg sprite icons
+ * @param {String} name
+ */
+export const getSvgIconPathContent = name =>
+ axios
+ .get(gon.sprite_icons)
+ .then(({ data: svgs }) =>
+ new DOMParser()
+ .parseFromString(svgs, 'text/xml')
+ .querySelector(`#${name} path`)
+ .getAttribute('d'),
+ )
+ .catch(() => null);
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 0deae478deb..ac3b47cd218 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -35,6 +35,7 @@ function MergeRequest(opts) {
dataType: 'merge_request',
fieldName: 'description',
selector: '.detail-page-description',
+ lockVersion: this.$el.data('lockVersion'),
onSuccess: result => {
document.querySelector('#task_status').innerText = result.task_status;
document.querySelector('#task_status_short').innerText = result.task_status_short;
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index e2cffe0b4b4..ec0e33a1927 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -1,10 +1,16 @@
<script>
-import { GlAreaChart } from '@gitlab/ui';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import Icon from '~/vue_shared/components/icon.vue';
+
+let debouncedResize;
export default {
components: {
GlAreaChart,
+ Icon,
},
inheritAttrs: false,
props: {
@@ -26,22 +32,38 @@ export default {
);
},
},
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
alertData: {
type: Object,
required: false,
default: () => ({}),
},
},
+ data() {
+ return {
+ tooltip: {
+ title: '',
+ content: '',
+ isDeployment: false,
+ sha: '',
+ },
+ width: 0,
+ height: 0,
+ scatterSymbol: undefined,
+ };
+ },
computed: {
chartData() {
return this.graphData.queries.reduce((accumulator, query) => {
- const xLabel = `${query.unit}`;
- accumulator[xLabel] = {};
- query.result.forEach(res =>
- res.values.forEach(v => {
- accumulator[xLabel][v.time.toISOString()] = v.value;
- }),
- );
+ accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
return accumulator;
}, {});
},
@@ -51,14 +73,17 @@ export default {
name: 'Time',
type: 'time',
axisLabel: {
- formatter: date => dateFormat(date, 'h:MMtt'),
+ formatter: date => dateFormat(date, 'h:MM TT'),
+ },
+ axisPointer: {
+ snap: true,
},
nameTextStyle: {
padding: [18, 0, 0, 0],
},
},
yAxis: {
- name: this.graphData.y_label,
+ name: this.yAxisLabel,
axisLabel: {
formatter: value => value.toFixed(3),
},
@@ -69,33 +94,129 @@ export default {
legend: {
formatter: this.xAxisLabel,
},
+ series: this.scatterSeries,
+ };
+ },
+ earliestDatapoint() {
+ return Object.values(this.chartData).reduce((acc, data) => {
+ const [[timestamp]] = data.sort(([a], [b]) => {
+ if (a < b) {
+ return -1;
+ }
+ return a > b ? 1 : 0;
+ });
+
+ return timestamp < acc || acc === null ? timestamp : acc;
+ }, null);
+ },
+ recentDeployments() {
+ return this.deploymentData.reduce((acc, deployment) => {
+ if (deployment.created_at >= this.earliestDatapoint) {
+ acc.push({
+ id: deployment.id,
+ createdAt: deployment.created_at,
+ sha: deployment.sha,
+ commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
+ tag: deployment.tag,
+ tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
+ ref: deployment.ref.name,
+ showDeploymentFlag: false,
+ });
+ }
+
+ return acc;
+ }, []);
+ },
+ scatterSeries() {
+ return {
+ type: 'scatter',
+ data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
+ symbol: this.scatterSymbol,
+ symbolSize: 14,
};
},
xAxisLabel() {
return this.graphData.queries.map(query => query.label).join(', ');
},
+ yAxisLabel() {
+ const [query] = this.graphData.queries;
+ return `${this.graphData.y_label} (${query.unit})`;
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ this.getScatterSymbol();
},
methods: {
formatTooltipText(params) {
- const [date, value] = params;
- return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
+ const [seriesData] = params.seriesData;
+ this.tooltip.isDeployment = seriesData.componentSubType === 'scatter';
+ this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ if (this.tooltip.isDeployment) {
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === seriesData.value[0],
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ } else {
+ this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`;
+ }
+ },
+ getScatterSymbol() {
+ getSvgIconPathContent('rocket')
+ .then(path => {
+ if (path) {
+ this.scatterSymbol = `path://${path}`;
+ }
+ })
+ .catch(() => {});
+ },
+ onResize() {
+ const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect();
+ this.width = width;
+ this.height = height;
},
},
};
</script>
<template>
- <div class="prometheus-graph">
+ <div class="prometheus-graph col-12 col-lg-6">
<div class="prometheus-graph-header">
<h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
<div class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
+ ref="areaChart"
v-bind="$attrs"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="alertData"
- />
+ :width="width"
+ :height="height"
+ >
+ <template slot="tooltipTitle">
+ <div v-if="tooltip.isDeployment">
+ {{ __('Deployed') }}
+ </div>
+ {{ tooltip.title }}
+ </template>
+ <template slot="tooltipContent">
+ <div v-if="tooltip.isDeployment" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ {{ tooltip.sha }}
+ </div>
+ <template v-else>
+ {{ tooltip.content }}
+ </template>
+ </template>
+ </gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 973fc8e10c9..9c5fd93f7d1 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,20 +1,19 @@
<script>
-import _ from 'underscore';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
-import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
import MonitoringStore from '../stores/monitoring_store';
-import eventHub from '../event_hub';
+
+const sidebarAnimationDuration = 150;
+let sidebarMutationObserver;
export default {
components: {
MonitorAreaChart,
- Graph,
GraphGroup,
EmptyState,
Icon,
@@ -25,21 +24,11 @@ export default {
required: false,
default: true,
},
- showLegend: {
- type: Boolean,
- required: false,
- default: true,
- },
showPanels: {
type: Boolean,
required: false,
default: true,
},
- forceSmallGraph: {
- type: Boolean,
- required: false,
- default: false,
- },
documentationPath: {
type: String,
required: true,
@@ -99,48 +88,32 @@ export default {
store: new MonitoringStore(),
state: 'gettingStarted',
showEmptyState: true,
- hoverData: {},
elWidth: 0,
};
},
- computed: {
- graphComponent() {
- return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
- },
- forceRedraw() {
- return this.elWidth;
- },
- },
created() {
this.service = new MonitoringService({
metricsEndpoint: this.metricsEndpoint,
deploymentEndpoint: this.deploymentEndpoint,
environmentsEndpoint: this.environmentsEndpoint,
});
- this.mutationObserverConfig = {
- attributes: true,
- childList: false,
- subtree: false,
- };
- eventHub.$on('hoverChanged', this.hoverChanged);
},
beforeDestroy() {
- eventHub.$off('hoverChanged', this.hoverChanged);
- window.removeEventListener('resize', this.resizeThrottled, false);
- this.sidebarMutationObserver.disconnect();
+ if (sidebarMutationObserver) {
+ sidebarMutationObserver.disconnect();
+ }
},
mounted() {
- this.resizeThrottled = _.debounce(this.resize, 100);
if (!this.hasMetrics) {
this.state = 'gettingStarted';
} else {
this.getGraphsData();
- window.addEventListener('resize', this.resizeThrottled, false);
-
- const sidebarEl = document.querySelector('.nav-sidebar');
- // The sidebar listener
- this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled);
- this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig);
+ sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
+ sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
+ attributes: true,
+ childList: false,
+ subtree: false,
+ });
}
},
methods: {
@@ -168,23 +141,21 @@ export default {
this.showEmptyState = false;
})
- .then(this.resize)
.catch(() => {
this.state = 'unableToConnect';
});
},
- resize() {
- this.elWidth = this.$el.clientWidth;
- },
- hoverChanged(data) {
- this.hoverData = data;
+ onSidebarMutation() {
+ setTimeout(() => {
+ this.elWidth = this.$el.clientWidth;
+ }, sidebarAnimationDuration);
},
},
};
</script>
<template>
- <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default">
+ <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default">
<div class="environments d-flex align-items-center">
{{ s__('Metrics|Environment') }}
<div class="dropdown prepend-left-10">
@@ -215,23 +186,15 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
- <component
- :is="graphComponent"
+ <monitor-area-chart
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
- :hover-data="hoverData"
:deployment-data="store.deploymentData"
- :project-path="projectPath"
- :tags-path="tagsPath"
- :show-legend="showLegend"
- :small-graph="forceSmallGraph"
:alert-data="getGraphAlerts(graphData.id)"
+ :container-width="elWidth"
group-id="monitor-area-chart"
- >
- <!-- EE content -->
- {{ null }}
- </component>
+ />
</graph-group>
</div>
<empty-state
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
deleted file mode 100644
index 309b73f5a4d..00000000000
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ /dev/null
@@ -1,329 +0,0 @@
-<script>
-import { scaleLinear, scaleTime } from 'd3-scale';
-import { axisLeft, axisBottom } from 'd3-axis';
-import _ from 'underscore';
-import { max, extent } from 'd3-array';
-import { select } from 'd3-selection';
-import GraphAxis from './graph/axis.vue';
-import GraphLegend from './graph/legend.vue';
-import GraphFlag from './graph/flag.vue';
-import GraphDeployment from './graph/deployment.vue';
-import GraphPath from './graph/path.vue';
-import MonitoringMixin from '../mixins/monitoring_mixins';
-import eventHub from '../event_hub';
-import measurements from '../utils/measurements';
-import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
-import createTimeSeries from '../utils/multiple_time_series';
-import bp from '../../breakpoints';
-
-const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
-
-export default {
- components: {
- GraphAxis,
- GraphFlag,
- GraphDeployment,
- GraphPath,
- GraphLegend,
- },
- mixins: [MonitoringMixin],
- props: {
- graphData: {
- type: Object,
- required: true,
- },
- deploymentData: {
- type: Array,
- required: true,
- },
- hoverData: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- projectPath: {
- type: String,
- required: true,
- },
- tagsPath: {
- type: String,
- required: true,
- },
- showLegend: {
- type: Boolean,
- required: false,
- default: true,
- },
- smallGraph: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- baseGraphHeight: 450,
- baseGraphWidth: 600,
- graphHeight: 450,
- graphWidth: 600,
- graphHeightOffset: 120,
- margin: {},
- unitOfDisplay: '',
- yAxisLabel: '',
- legendTitle: '',
- reducedDeploymentData: [],
- measurements: measurements.large,
- currentData: {
- time: new Date(),
- value: 0,
- },
- currentXCoordinate: 0,
- currentCoordinates: {},
- showFlag: false,
- showFlagContent: false,
- timeSeries: [],
- graphDrawData: {},
- realPixelRatio: 1,
- seriesUnderMouse: [],
- };
- },
- computed: {
- outerViewBox() {
- return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
- },
- innerViewBox() {
- return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
- },
- axisTransform() {
- return `translate(70, ${this.graphHeight - 100})`;
- },
- paddingBottomRootSvg() {
- return {
- paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`,
- };
- },
- deploymentFlagData() {
- return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
- },
- shouldRenderData() {
- return this.graphData.queries.filter(s => s.result.length > 0).length > 0;
- },
- },
- watch: {
- hoverData() {
- this.positionFlag();
- },
- },
- mounted() {
- this.draw();
- },
- methods: {
- showDot(path) {
- return this.showFlagContent && this.seriesUnderMouse.includes(path);
- },
- draw() {
- const breakpointSize = bp.getBreakpointSize();
- const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width;
-
- this.margin = measurements.large.margin;
-
- if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
- this.graphHeight = 300;
- this.margin = measurements.small.margin;
- this.measurements = measurements.small;
- }
-
- this.yAxisLabel = this.graphData.y_label || 'Values';
- this.graphWidth = svgWidth - this.margin.left - this.margin.right;
- this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
- this.baseGraphHeight = this.graphHeight - 50;
- this.baseGraphWidth = this.graphWidth;
-
- // pixel offsets inside the svg and outside are not 1:1
- this.realPixelRatio = svgWidth / this.baseGraphWidth;
-
- // set the legends on the axes
- const [query] = this.graphData.queries;
- this.legendTitle = query ? query.label : 'Average';
- this.unitOfDisplay = query ? query.unit : '';
-
- if (this.shouldRenderData) {
- this.renderAxesPaths();
- this.formatDeployments();
- }
- },
- handleMouseOverGraph(e) {
- let point = this.$refs.graphData.createSVGPoint();
- point.x = e.clientX;
- point.y = e.clientY;
- point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
- point.x += 7;
-
- this.seriesUnderMouse = this.timeSeries.filter(series => {
- const mouseX = series.timeSeriesScaleX.invert(point.x);
- let minDistance = Infinity;
-
- const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => {
- const distance = Math.abs(Number(new Date(x)) - Number(mouseX));
- if (distance < minDistance) {
- minDistance = distance;
- return x;
- }
- return closest;
- });
-
- return series.values.find(v => v.time.toString() === closestTickMark);
- });
-
- const firstTimeSeries = this.seriesUnderMouse[0];
- const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
- const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
- const d0 = firstTimeSeries.values[overlayIndex - 1];
- const d1 = firstTimeSeries.values[overlayIndex];
- if (d0 === undefined || d1 === undefined) return;
- const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
- const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1;
- const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
- const currentDeployXPos = this.mouseOverDeployInfo(point.x);
-
- eventHub.$emit('hoverChanged', {
- hoveredDate,
- currentDeployXPos,
- });
- },
- renderAxesPaths() {
- ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries(
- this.graphData.queries,
- this.graphWidth,
- this.graphHeight,
- this.graphHeightOffset,
- ));
-
- if (_.findWhere(this.timeSeries, { renderCanary: true })) {
- this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
- }
-
- const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
- const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]);
-
- const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
- axisXScale.domain(d3.extent(allValues, d => d.time));
- axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
-
- this.allXAxisValues = this.timeSeries.reduce((obj, series) => {
- const seriesKeys = {};
- series.values.forEach(v => {
- seriesKeys[v.time] = true;
- });
- return {
- ...obj,
- ...seriesKeys,
- };
- }, {});
-
- const xAxis = d3
- .axisBottom()
- .scale(axisXScale)
- .ticks(this.graphWidth / 120)
- .tickFormat(timeScaleFormat);
-
- const yAxis = d3
- .axisLeft()
- .scale(axisYScale)
- .ticks(measurements.yTicks);
-
- d3.select(this.$refs.baseSvg)
- .select('.x-axis')
- .call(xAxis);
-
- const width = this.graphWidth;
- d3.select(this.$refs.baseSvg)
- .select('.y-axis')
- .call(yAxis)
- .selectAll('.tick')
- .each(function createTickLines(d, i) {
- if (i > 0) {
- d3.select(this)
- .select('line')
- .attr('x2', width)
- .attr('class', 'axis-tick');
- } // Avoid adding the class to the first tick, to prevent coloring
- }); // This will select all of the ticks once they're rendered
- },
- },
-};
-</script>
-
-<template>
- <div
- class="prometheus-graph"
- @mouseover="showFlagContent = true"
- @mouseleave="showFlagContent = false"
- >
- <div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets"><slot></slot></div>
- </div>
- <div :style="paddingBottomRootSvg" class="prometheus-svg-container">
- <svg ref="baseSvg" :viewBox="outerViewBox">
- <g :transform="axisTransform" class="x-axis" />
- <g class="y-axis" transform="translate(70, 20)" />
- <graph-axis
- :graph-width="graphWidth"
- :graph-height="graphHeight"
- :margin="margin"
- :measurements="measurements"
- :y-axis-label="yAxisLabel"
- :unit-of-display="unitOfDisplay"
- />
- <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data">
- <slot name="additionalSvgContent" :graphDrawData="graphDrawData" />
- <graph-path
- v-for="(path, index) in timeSeries"
- :key="index"
- :generated-line-path="path.linePath"
- :generated-area-path="path.areaPath"
- :line-style="path.lineStyle"
- :line-color="path.lineColor"
- :area-color="path.areaColor"
- :current-coordinates="currentCoordinates[path.metricTag]"
- :show-dot="showDot(path)"
- />
- <graph-deployment
- :deployment-data="reducedDeploymentData"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- />
- <rect
- ref="graphOverlay"
- :width="graphWidth - 70"
- :height="graphHeight - 100"
- class="prometheus-graph-overlay"
- transform="translate(-5, 20)"
- @mousemove="handleMouseOverGraph($event)"
- />
- </svg>
- <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display">
- <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle">
- {{ s__('Metrics|No data to display') }}
- </text>
- </svg>
- </svg>
- <graph-flag
- v-if="shouldRenderData"
- :real-pixel-ratio="realPixelRatio"
- :current-x-coordinate="currentXCoordinate"
- :current-data="currentData"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- :show-flag-content="showFlagContent"
- :time-series="seriesUnderMouse"
- :unit-of-display="unitOfDisplay"
- :legend-title="legendTitle"
- :deployment-flag-data="deploymentFlagData"
- :current-coordinates="currentCoordinates"
- />
- </div>
- <graph-legend v-if="showLegend" :legend-title="legendTitle" :time-series="timeSeries" />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue
deleted file mode 100644
index 8f046857a20..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/axis.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<script>
-import { convertToSentenceCase } from '~/lib/utils/text_utility';
-import { s__ } from '~/locale';
-
-export default {
- props: {
- graphWidth: {
- type: Number,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- margin: {
- type: Object,
- required: true,
- },
- measurements: {
- type: Object,
- required: true,
- },
- yAxisLabel: {
- type: String,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- yLabelWidth: 0,
- yLabelHeight: 0,
- };
- },
- computed: {
- textTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0;
-
- return `translate(15, ${yCoordinate}) rotate(-90)`;
- },
-
- rectTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 +
- this.yLabelWidth / 2 || 0;
-
- return `translate(0, ${yCoordinate}) rotate(-90)`;
- },
-
- xPosition() {
- return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0;
- },
-
- yPosition() {
- return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0;
- },
-
- yAxisLabelSentenceCase() {
- return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
- },
-
- timeString() {
- return s__('PrometheusDashboard|Time');
- },
- },
- mounted() {
- this.$nextTick(() => {
- const bbox = this.$refs.ylabel.getBBox();
- this.yLabelWidth = bbox.width + 10; // Added some padding
- this.yLabelHeight = bbox.height + 5;
- });
- },
-};
-</script>
-<template>
- <g class="axis-label-container">
- <line
- :y1="yPosition"
- :x2="graphWidth + 20"
- :y2="yPosition"
- class="label-x-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- />
- <line
- :x2="10"
- :y2="yPosition"
- class="label-y-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- y1="0"
- />
- <rect
- :transform="rectTransform"
- :width="yLabelWidth"
- :height="yLabelHeight"
- class="rect-axis-text"
- />
- <text
- ref="ylabel"
- :transform="textTransform"
- class="label-axis-text y-label-text"
- text-anchor="middle"
- >
- {{ yAxisLabelSentenceCase }}
- </text>
- <rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" />
- <text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em">
- {{ timeString }}
- </text>
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue
deleted file mode 100644
index bee9784692c..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/deployment.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-export default {
- props: {
- deploymentData: {
- type: Array,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- },
- computed: {
- calculatedHeight() {
- return this.graphHeight - this.graphHeightOffset;
- },
- },
- methods: {
- transformDeploymentGroup(deployment) {
- return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
- },
- },
-};
-</script>
-<template>
- <g class="deploy-info">
- <g
- v-for="(deployment, index) in deploymentData"
- :key="index"
- :transform="transformDeploymentGroup(deployment)"
- >
- <rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" />
- <line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" />
- </g>
- <svg height="0" width="0">
- <defs>
- <linearGradient id="shadow-gradient">
- <stop offset="0%" stop-color="#000" stop-opacity="0.4" />
- <stop offset="100%" stop-color="#000" stop-opacity="0" />
- </linearGradient>
- </defs>
- </svg>
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
deleted file mode 100644
index 9d6d1caef80..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ /dev/null
@@ -1,151 +0,0 @@
-<script>
-import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
-import { formatRelevantDigits } from '../../../lib/utils/number_utils';
-import Icon from '../../../vue_shared/components/icon.vue';
-import TrackLine from './track_line.vue';
-
-export default {
- components: {
- Icon,
- TrackLine,
- },
- props: {
- currentXCoordinate: {
- type: Number,
- required: true,
- },
- currentData: {
- type: Object,
- required: true,
- },
- deploymentFlagData: {
- type: Object,
- required: false,
- default: null,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- realPixelRatio: {
- type: Number,
- required: true,
- },
- showFlagContent: {
- type: Boolean,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- legendTitle: {
- type: String,
- required: true,
- },
- currentCoordinates: {
- type: Object,
- required: true,
- },
- },
- computed: {
- formatTime() {
- return this.deploymentFlagData
- ? timeFormat(this.deploymentFlagData.time)
- : timeFormat(this.currentData.time);
- },
- formatDate() {
- return this.deploymentFlagData
- ? dateFormat(this.deploymentFlagData.time)
- : dateFormat(this.currentData.time);
- },
- cursorStyle() {
- const xCoordinate = this.deploymentFlagData
- ? this.deploymentFlagData.xPos
- : this.currentXCoordinate;
-
- const offsetTop = 20 * this.realPixelRatio;
- const offsetLeft = (70 + xCoordinate) * this.realPixelRatio;
- const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio;
-
- return {
- top: `${offsetTop}px`,
- left: `${offsetLeft}px`,
- height: `${height}px`,
- };
- },
- flagOrientation() {
- if (this.currentXCoordinate * this.realPixelRatio > 120) {
- return 'left';
- }
- return 'right';
- },
- },
- methods: {
- seriesMetricValue(seriesIndex, series) {
- const indexFromCoordinates = this.currentCoordinates[series.metricTag]
- ? this.currentCoordinates[series.metricTag].currentDataIndex
- : 0;
- const index = this.deploymentFlagData
- ? this.deploymentFlagData.seriesIndex
- : indexFromCoordinates;
- const value = series.values[index] && series.values[index].value;
- if (Number.isNaN(value)) {
- return '-';
- }
- return `${formatRelevantDigits(value)}${this.unitOfDisplay}`;
- },
- seriesMetricLabel(index, series) {
- if (this.timeSeries.length < 2) {
- return this.legendTitle;
- }
- if (series.metricTag) {
- return series.metricTag;
- }
- return `series ${index + 1}`;
- },
- },
-};
-</script>
-
-<template>
- <div :style="cursorStyle" class="prometheus-graph-cursor">
- <div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover">
- <div class="arrow-shadow"></div>
- <div class="arrow"></div>
- <div class="popover-title">
- <h5 v-if="deploymentFlagData">Deployed</h5>
- {{ formatDate }} <strong>{{ formatTime }}</strong>
- </div>
- <div v-if="deploymentFlagData" class="popover-content deploy-meta-content">
- <div>
- <icon :size="12" name="commit" />
- <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a>
- </div>
- <div v-if="deploymentFlagData.tag">
- <icon :size="12" name="label" />
- <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a>
- </div>
- </div>
- <div class="popover-content">
- <table class="prometheus-table">
- <tr v-for="(series, index) in timeSeries" :key="index">
- <track-line :track="series" />
- <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
- <td>
- <strong>{{ seriesMetricValue(index, series) }}</strong>
- </td>
- </tr>
- </table>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
deleted file mode 100644
index b5211c306a3..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<script>
-import TrackLine from './track_line.vue';
-import TrackInfo from './track_info.vue';
-
-export default {
- components: {
- TrackLine,
- TrackInfo,
- },
- props: {
- legendTitle: {
- type: String,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- },
- methods: {
- isStable(track) {
- return {
- 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
- };
- },
- },
-};
-</script>
-<template>
- <div class="prometheus-graph-legends prepend-left-10">
- <table class="prometheus-table">
- <tr
- v-for="(series, index) in timeSeries"
- v-if="series.shouldRenderLegend"
- :key="index"
- :class="isStable(series)"
- >
- <td>
- <strong v-if="series.renderCanary">{{ series.trackName }}</strong>
- </td>
- <track-line :track="series" />
- <td v-if="timeSeries.length > 1" class="legend-metric-title">
- <track-info v-if="series.metricTag" :track="series" />
- <track-info v-else :track="series">
- <strong>{{ legendTitle }}</strong> series {{ index + 1 }}
- </track-info>
- </td>
- <td v-else>
- <track-info :track="series">
- <strong>{{ legendTitle }}</strong>
- </track-info>
- </td>
- <template v-for="(track, trackIndex) in series.tracksLegend">
- <track-line :key="`track-line-${trackIndex}`" :track="track" />
- <td :key="`track-info-${trackIndex}`">
- <track-info :track="track" class="legend-metric-title" />
- </td>
- </template>
- </tr>
- </table>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue
deleted file mode 100644
index f2c237ec391..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/path.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-export default {
- props: {
- generatedLinePath: {
- type: String,
- required: true,
- },
- generatedAreaPath: {
- type: String,
- required: true,
- },
- lineStyle: {
- type: String,
- required: false,
- default: '',
- },
- lineColor: {
- type: String,
- required: true,
- },
- areaColor: {
- type: String,
- required: true,
- },
- currentCoordinates: {
- type: Object,
- required: false,
- default: () => ({ currentX: 0, currentY: 0 }),
- },
- showDot: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- strokeDashArray() {
- if (this.lineStyle === 'dashed') return '3, 1';
- if (this.lineStyle === 'dotted') return '1, 1';
- return null;
- },
- },
-};
-</script>
-<template>
- <g transform="translate(-5, 20)">
- <circle
- v-if="showDot"
- :cx="currentCoordinates.currentX"
- :cy="currentCoordinates.currentY"
- :fill="lineColor"
- :stroke="lineColor"
- class="circle-path"
- r="3"
- />
- <path :d="generatedAreaPath" :fill="areaColor" class="metric-area" />
- <path
- :d="generatedLinePath"
- :stroke="lineColor"
- :stroke-dasharray="strokeDashArray"
- class="metric-line"
- fill="none"
- stroke-width="1"
- />
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue
deleted file mode 100644
index 3464067834f..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/track_info.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { formatRelevantDigits } from '~/lib/utils/number_utils';
-
-export default {
- name: 'TrackInfo',
- props: {
- track: {
- type: Object,
- required: true,
- },
- },
- computed: {
- summaryMetrics() {
- return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
- this.track.max,
- )}`;
- },
- },
-};
-</script>
-<template>
- <span>
- <slot>
- <strong> {{ track.metricTag }} </strong>
- </slot>
- {{ summaryMetrics }}
- </span>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue
deleted file mode 100644
index d2ed1ba113e..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/track_line.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-export default {
- name: 'TrackLine',
- props: {
- track: {
- type: Object,
- required: true,
- },
- },
- computed: {
- stylizedLine() {
- if (this.track.lineStyle === 'dashed') return '6, 3';
- if (this.track.lineStyle === 'dotted') return '3, 3';
- return null;
- },
- },
-};
-</script>
-<template>
- <td>
- <svg width="16" height="8">
- <line
- :stroke-dasharray="stylizedLine"
- :stroke="track.lineColor"
- :x1="0"
- :x2="16"
- :y1="4"
- :y2="4"
- stroke-width="4"
- />
- </svg>
- </td>
-</template>
diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js
deleted file mode 100644
index 0948c2e5352..00000000000
--- a/app/assets/javascripts/monitoring/event_hub.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import Vue from 'vue';
-
-export default new Vue();
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
deleted file mode 100644
index 87c3d969de4..00000000000
--- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { bisectDate } from '../utils/date_time_formatters';
-
-const mixins = {
- methods: {
- mouseOverDeployInfo(mouseXPos) {
- if (!this.reducedDeploymentData) return false;
-
- let dataFound = false;
- this.reducedDeploymentData = this.reducedDeploymentData.map(d => {
- const deployment = d;
- if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
- dataFound = d.xPos + 1;
-
- deployment.showDeploymentFlag = true;
- } else {
- deployment.showDeploymentFlag = false;
- }
- return deployment;
- });
-
- return dataFound;
- },
-
- formatDeployments() {
- this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
- const time = new Date(deployment.created_at);
- const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
-
- time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
-
- if (xPos >= 0) {
- const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1);
-
- deploymentDataArray.push({
- id: deployment.id,
- time,
- sha: deployment.sha,
- commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
- tag: deployment.tag,
- tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
- ref: deployment.ref.name,
- xPos,
- seriesIndex,
- showDeploymentFlag: false,
- });
- }
-
- return deploymentDataArray;
- }, []);
- },
-
- positionFlag() {
- const timeSeries = this.seriesUnderMouse[0];
- if (!timeSeries) {
- return;
- }
- const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate);
-
- this.currentData = timeSeries.values[hoveredDataIndex];
- this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time));
-
- this.currentCoordinates = {};
-
- this.seriesUnderMouse.forEach(series => {
- const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate);
- const currentData = series.values[currentDataIndex];
- const currentX = Math.floor(series.timeSeriesScaleX(currentData.time));
- const currentY = Math.floor(series.timeSeriesScaleY(currentData.value));
-
- this.currentCoordinates[series.metricTag] = {
- currentX,
- currentY,
- currentDataIndex,
- };
- });
-
- if (this.hoverData.currentDeployXPos) {
- this.showFlag = false;
- } else {
- this.showFlag = true;
- }
- },
- },
-};
-
-export default mixins;
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 96ecc5ab8a8..70635059bd9 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -13,7 +13,7 @@ function checkQueryEmptyData(query) {
result: query.result.filter(timeSeries => {
const newTimeSeries = timeSeries;
const hasValue = series =>
- !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined);
+ !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined);
const hasNonNullValue = timeSeries.values.find(hasValue);
newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
@@ -33,10 +33,10 @@ function normalizeMetrics(metrics) {
...query,
result: query.result.map(result => ({
...result,
- values: result.values.map(([timestamp, value]) => ({
- time: new Date(timestamp * 1000),
- value: Number(value),
- })),
+ values: result.values.map(([timestamp, value]) => [
+ new Date(timestamp * 1000).toISOString(),
+ Number(value),
+ ]),
})),
}));
diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
deleted file mode 100644
index d88c13609dc..00000000000
--- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { timeFormat as time } from 'd3-time-format';
-import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
-import { bisector } from 'd3-array';
-
-const d3 = {
- time,
- bisector,
- timeSecond,
- timeMinute,
- timeHour,
- timeDay,
- timeWeek,
- timeMonth,
- timeYear,
-};
-
-export const dateFormat = d3.time('%d %b %Y, ');
-export const timeFormat = d3.time('%-I:%M%p');
-export const dateFormatWithName = d3.time('%a, %b %-d');
-export const bisectDate = d3.bisector(d => d.time).left;
-
-export function timeScaleFormat(date) {
- let formatFunction;
- if (d3.timeSecond(date) < date) {
- formatFunction = d3.time('.%L');
- } else if (d3.timeMinute(date) < date) {
- formatFunction = d3.time(':%S');
- } else if (d3.timeHour(date) < date) {
- formatFunction = d3.time('%-I:%M');
- } else if (d3.timeDay(date) < date) {
- formatFunction = d3.time('%-I %p');
- } else if (d3.timeWeek(date) < date) {
- formatFunction = d3.time('%a %d');
- } else if (d3.timeMonth(date) < date) {
- formatFunction = d3.time('%b %d');
- } else if (d3.timeYear(date) < date) {
- formatFunction = d3.time('%B');
- } else {
- formatFunction = d3.time('%Y');
- }
- return formatFunction(date);
-}
diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js
deleted file mode 100644
index 7c771f43eee..00000000000
--- a/app/assets/javascripts/monitoring/utils/measurements.js
+++ /dev/null
@@ -1,44 +0,0 @@
-export default {
- small: {
- // Covers both xs and sm screen sizes
- margin: {
- top: 40,
- right: 40,
- bottom: 50,
- left: 40,
- },
- legends: {
- width: 15,
- height: 3,
- offsetX: 20,
- offsetY: 32,
- },
- backgroundLegend: {
- width: 30,
- height: 50,
- },
- axisLabelLineOffset: -20,
- },
- large: {
- // This covers both md and lg screen sizes
- margin: {
- top: 80,
- right: 80,
- bottom: 100,
- left: 80,
- },
- legends: {
- width: 15,
- height: 3,
- offsetX: 20,
- offsetY: 34,
- },
- backgroundLegend: {
- width: 30,
- height: 150,
- },
- axisLabelLineOffset: 20,
- },
- xTicks: 8,
- yTicks: 3,
-};
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
deleted file mode 100644
index 50ba14dfb2e..00000000000
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import _ from 'underscore';
-import { scaleLinear, scaleTime } from 'd3-scale';
-import { line, area, curveLinear } from 'd3-shape';
-import { extent, max, sum } from 'd3-array';
-import { timeMinute, timeSecond } from 'd3-time';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-const d3 = {
- scaleLinear,
- scaleTime,
- line,
- area,
- curveLinear,
- extent,
- max,
- timeMinute,
- timeSecond,
- sum,
-};
-
-const defaultColorPalette = {
- blue: ['#1f78d1', '#8fbce8'],
- orange: ['#fc9403', '#feca81'],
- red: ['#db3b21', '#ed9d90'],
- green: ['#1aaa55', '#8dd5aa'],
- purple: ['#6666c4', '#d1d1f0'],
-};
-
-const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
-
-const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
-
-function queryTimeSeries(query, graphDrawData, lineStyle) {
- let usedColors = [];
- let renderCanary = false;
- const timeSeriesParsed = [];
-
- function pickColor(name) {
- let pick;
- if (name && defaultColorPalette[name]) {
- pick = name;
- } else {
- const unusedColors = _.difference(defaultColorOrder, usedColors);
- if (unusedColors.length > 0) {
- [pick] = unusedColors;
- } else {
- usedColors = [];
- [pick] = defaultColorOrder;
- }
- }
- usedColors.push(pick);
- return defaultColorPalette[pick];
- }
-
- function findByDate(series, time) {
- const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60);
- if (val) {
- return val.value;
- }
- return NaN;
- }
-
- // The timeseries data may have gaps in it
- // but we need a regularly-spaced set of time/value pairs
- // this gives us a complete range of one minute intervals
- // offset the same amount as the original data
- const [minX, maxX] = graphDrawData.xDom;
- const offset = d3.timeMinute(minX) - Number(minX);
- const datesWithoutGaps = d3.timeSecond
- .every(60)
- .range(d3.timeMinute.offset(minX, -1), maxX)
- .map(d => d - offset);
-
- query.result.forEach((timeSeries, timeSeriesNumber) => {
- let metricTag = '';
- let lineColor = '';
- let areaColor = '';
- let shouldRenderLegend = true;
- const timeSeriesValues = timeSeries.values.map(d => d.value);
- const maximumValue = d3.max(timeSeriesValues);
- const accum = d3.sum(timeSeriesValues);
- const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
-
- if (trackName === 'Canary') {
- renderCanary = true;
- }
-
- const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
- const seriesCustomizationData =
- query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
-
- if (seriesCustomizationData) {
- metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
- [lineColor, areaColor] = pickColor(seriesCustomizationData.color);
- if (timeSeriesParsed.length > 0) {
- shouldRenderLegend = false;
- } else {
- shouldRenderLegend = true;
- }
- } else {
- metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
- [lineColor, areaColor] = pickColor();
- if (timeSeriesParsed.length > 1) {
- shouldRenderLegend = false;
- }
- }
-
- const values = datesWithoutGaps.map(time => ({
- time,
- value: findByDate(timeSeries.values, time),
- }));
-
- timeSeriesParsed.push({
- linePath: graphDrawData.lineFunction(values),
- areaPath: graphDrawData.areaBelowLine(values),
- timeSeriesScaleX: graphDrawData.timeSeriesScaleX,
- timeSeriesScaleY: graphDrawData.timeSeriesScaleY,
- values: timeSeries.values,
- max: maximumValue,
- average: accum / timeSeries.values.length,
- lineStyle,
- lineColor,
- areaColor,
- metricTag,
- trackName,
- shouldRenderLegend,
- renderCanary,
- });
-
- if (!shouldRenderLegend) {
- if (!timeSeriesParsed[0].tracksLegend) {
- timeSeriesParsed[0].tracksLegend = [];
- }
- timeSeriesParsed[0].tracksLegend.push({
- max: maximumValue,
- average: accum / timeSeries.values.length,
- lineStyle,
- lineColor,
- metricTag,
- });
- }
- });
-
- return timeSeriesParsed;
-}
-
-function xyDomain(queries) {
- const allValues = queries.reduce(
- (allQueryResults, query) =>
- allQueryResults.concat(
- query.result.reduce((allResults, result) => allResults.concat(result.values), []),
- ),
- [],
- );
-
- const xDom = d3.extent(allValues, d => d.time);
- const yDom = [0, d3.max(allValues.map(d => d.value))];
-
- return {
- xDom,
- yDom,
- };
-}
-
-export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) {
- const { xDom, yDom } = xyDomain(queries);
-
- const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
- const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
-
- timeSeriesScaleX.domain(xDom);
- timeSeriesScaleX.ticks(d3.timeMinute, 60);
- timeSeriesScaleY.domain(yDom);
-
- const defined = d => !Number.isNaN(d.value) && d.value != null;
-
- const lineFunction = d3
- .line()
- .defined(defined)
- .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
- .x(d => timeSeriesScaleX(d.time))
- .y(d => timeSeriesScaleY(d.value));
-
- const areaBelowLine = d3
- .area()
- .defined(defined)
- .curve(d3.curveLinear)
- .x(d => timeSeriesScaleX(d.time))
- .y0(graphHeight - graphHeightOffset)
- .y1(d => timeSeriesScaleY(d.value));
-
- const areaAboveLine = d3
- .area()
- .defined(defined)
- .curve(d3.curveLinear)
- .x(d => timeSeriesScaleX(d.time))
- .y0(0)
- .y1(d => timeSeriesScaleY(d.value));
-
- return {
- lineFunction,
- areaBelowLine,
- areaAboveLine,
- xDom,
- yDom,
- timeSeriesScaleX,
- timeSeriesScaleY,
- };
-}
-
-export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
- const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset);
-
- const timeSeries = queries.reduce((series, query, index) => {
- const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length];
- return series.concat(queryTimeSeries(query, graphDrawData, lineStyle));
- }, []);
-
- return {
- timeSeries,
- graphDrawData,
- };
-}
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index c3443c300e3..c9c01354333 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1239,15 +1239,13 @@ export default class Notes {
var postUrl = $originalContentEl.data('postUrl');
var targetId = $originalContentEl.data('targetId');
var targetType = $originalContentEl.data('targetType');
- var markdownVersion = $originalContentEl.data('markdownVersion');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm
.find('form')
.attr('action', `${postUrl}?html=true`)
- .attr('data-remote', 'true')
- .attr('data-markdown-version', markdownVersion);
+ .attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
$editForm
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index d669ba5a8fa..1d6cb9485f7 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -39,11 +39,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
},
data() {
return {
@@ -342,7 +337,6 @@ Please check your network connection and try again.`;
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
- :markdown-version="markdownVersion"
:add-spacing-classes="false"
>
<textarea
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
new file mode 100644
index 00000000000..ea590905e3c
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ name: 'ReplyPlaceholder',
+};
+</script>
+
+<template>
+ <button
+ ref="button"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ :title="s__('MergeRequests|Add a reply')"
+ @click="$emit('onClick')"
+ >
+ {{ s__('MergeRequests|Reply...') }}
+ </button>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index d99694b06e9..394f2a80a67 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -2,11 +2,13 @@
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import ReplyButton from './note_actions/reply_button.vue';
export default {
name: 'NoteActions',
components: {
Icon,
+ ReplyButton,
GlLoadingIcon,
},
directives: {
@@ -21,6 +23,11 @@ export default {
type: [String, Number],
required: true,
},
+ discussionId: {
+ type: String,
+ required: false,
+ default: '',
+ },
noteUrl: {
type: String,
required: false,
@@ -36,6 +43,10 @@ export default {
required: false,
default: null,
},
+ showReply: {
+ type: Boolean,
+ required: true,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -80,6 +91,9 @@ export default {
},
computed: {
...mapGetters(['getUserDataByProp']),
+ showReplyButton() {
+ return gon.features && gon.features.replyToIndividualNotes && this.showReply;
+ },
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -153,6 +167,12 @@ export default {
<icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" />
</a>
</div>
+ <reply-button
+ v-if="showReplyButton"
+ ref="replyButton"
+ class="js-reply-button"
+ :note-id="discussionId"
+ />
<div v-if="canEdit" class="note-actions-item">
<button
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
new file mode 100644
index 00000000000..b2f9d7f128a
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -0,0 +1,40 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReplyButton',
+ components: {
+ Icon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ noteId: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['convertToDiscussion']),
+ },
+};
+</script>
+
+<template>
+ <div class="note-actions-item">
+ <gl-button
+ ref="button"
+ v-gl-tooltip.bottom
+ class="note-action-button"
+ variant="transparent"
+ :title="__('Reply to comment')"
+ @click="convertToDiscussion(noteId)"
+ >
+ <icon name="comment" css-classes="link-highlight" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index bcf5d334da4..ff303d0f55a 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -111,7 +111,6 @@ export default {
:line="line"
:note="note"
:help-page-path="helpPagePath"
- :markdown-version="note.cached_markdown_version"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
/>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 269b4a4b117..92258a25438 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -26,11 +26,6 @@ export default {
required: false,
default: '',
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
saveButtonTitle: {
type: String,
required: false,
@@ -202,7 +197,6 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 695efe3602f..b7e9f7c2028 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -24,6 +24,7 @@ import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation';
+import ReplyPlaceholder from './discussion_reply_placeholder.vue';
import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue';
export default {
@@ -39,6 +40,7 @@ export default {
resolveDiscussionButton,
jumpToNextDiscussionButton,
toggleRepliesWidget,
+ ReplyPlaceholder,
placeholderNote,
placeholderSystemNote,
systemNote,
@@ -396,6 +398,7 @@ Please check your network connection and try again.`;
:line="line"
:commit="commit"
:help-page-path="helpPagePath"
+ :show-reply-button="canReply"
@handleDeleteNote="deleteNoteHandler"
>
<note-edited-text
@@ -447,14 +450,7 @@ Please check your network connection and try again.`;
>
<template v-if="!isReplying && canReply">
<div class="discussion-with-resolve-btn">
- <button
- type="button"
- class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply"
- title="Add a reply"
- @click="showReplyForm"
- >
- Reply...
- </button>
+ <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" />
<resolve-discussion-button
v-if="discussion.resolvable"
:is-resolving="isResolving"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3c48d81ed05..56108a58010 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -29,6 +29,11 @@ export default {
type: Object,
required: true,
},
+ discussion: {
+ type: Object,
+ required: false,
+ default: null,
+ },
line: {
type: Object,
required: false,
@@ -54,7 +59,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']),
+ ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
author() {
return this.note.author;
},
@@ -80,6 +85,19 @@ export default {
isTarget() {
return this.targetNoteHash === this.noteAnchorId;
},
+ discussionId() {
+ if (this.discussion) {
+ return this.discussion.id;
+ }
+ return '';
+ },
+ showReplyButton() {
+ if (!this.discussion || !this.getNoteableData.current_user.can_create_note) {
+ return false;
+ }
+
+ return this.discussion.individual_note && !this.commentsDisabled;
+ },
actionText() {
if (!this.commit) {
return '';
@@ -231,6 +249,7 @@ export default {
:note-id="note.id"
:note-url="note.noteable_note_url"
:access-level="note.human_access"
+ :show-reply="showReplyButton"
:can-edit="note.current_user.can_edit"
:can-award-emoji="note.current_user.can_award_emoji"
:can-delete="note.current_user.can_edit"
@@ -241,6 +260,7 @@ export default {
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
+ :discussion-id="discussionId"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index f3fcfdfda05..6d72b72e628 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -44,11 +44,6 @@ export default {
required: false,
default: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
helpPagePath: {
type: String,
required: false,
@@ -204,7 +199,12 @@ export default {
:key="discussion.id"
:note="discussion.notes[0]"
/>
- <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" />
+ <noteable-note
+ v-else
+ :key="discussion.id"
+ :note="discussion.notes[0]"
+ :discussion="discussion"
+ />
</template>
<noteable-discussion
v-else
@@ -216,10 +216,6 @@ export default {
</template>
</ul>
- <comment-form
- v-if="!commentsDisabled"
- :noteable-type="noteableType"
- :markdown-version="markdownVersion"
- />
+ <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" />
</div>
</template>
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2f715c85fa6..4883266dae5 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -18,7 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
- const markdownVersion = parseInt(notesDataset.markdownVersion, 10);
let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType;
@@ -37,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => {
return {
noteableData,
currentUserData,
- markdownVersion,
notesData: JSON.parse(notesDataset.notesData),
};
},
@@ -47,7 +45,6 @@ document.addEventListener('DOMContentLoaded', () => {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
- markdownVersion: this.markdownVersion,
},
});
},
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 2105a62cecb..ff65f14d529 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -426,5 +426,8 @@ export const submitSuggestion = (
});
};
+export const convertToDiscussion = ({ commit }, noteId) =>
+ commit(types.CONVERT_TO_DISCUSSION, noteId);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index df943c155f4..2bffedad336 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -17,6 +17,7 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
+export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 33d39ad2ec9..d167f8ef421 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -264,4 +264,9 @@ export default {
).length;
state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
},
+
+ [types.CONVERT_TO_DISCUSSION](state, discussionId) {
+ const discussion = utils.findNoteObjectById(state.discussions, discussionId);
+ Object.assign(discussion, { individual_note: false });
+ },
};
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index c7ce4675573..0dd0d5336fc 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import createFlash from '~/flash';
import GfmAutoComplete from '~/gfm_auto_complete';
+import emojiRegex from 'emoji-regex';
import EmojiMenu from './emoji_menu';
const defaultStatusEmoji = 'speech_balloon';
@@ -42,6 +43,17 @@ document.addEventListener('DOMContentLoaded', () => {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+ const userNameInput = document.getElementById('user_name');
+ userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity('Invalid field');
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+ });
+
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
const emojiMenu = new EmojiMenu(
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index cf9db89e32b..2b32a6e4a98 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -108,7 +108,7 @@ export default {
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
- class="js-pipeline-graph-job-link"
+ class="js-pipeline-graph-job-link qa-job-link"
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue
new file mode 100644
index 00000000000..4d18c5c4bdd
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/environment_row.vue
@@ -0,0 +1,65 @@
+<script>
+import FunctionRow from './function_row.vue';
+import ItemCaret from '~/groups/components/item_caret.vue';
+
+export default {
+ components: {
+ ItemCaret,
+ FunctionRow,
+ },
+ props: {
+ env: {
+ type: Array,
+ required: true,
+ },
+ envName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
+ computed: {
+ envId() {
+ if (this.envName === '*') {
+ return 'env-global';
+ }
+
+ return `env-${this.envName}`;
+ },
+ isOpenClass() {
+ return {
+ 'is-open': this.isOpen,
+ };
+ },
+ },
+ methods: {
+ toggleOpen() {
+ this.isOpen = !this.isOpen;
+ },
+ },
+};
+</script>
+
+<template>
+ <li :id="envId" :class="isOpenClass" class="group-row has-children">
+ <div
+ class="group-row-contents d-flex justify-content-end align-items-center"
+ role="button"
+ @click.stop="toggleOpen"
+ >
+ <div class="folder-toggle-wrap d-flex align-items-center">
+ <item-caret :is-group-open="isOpen" />
+ </div>
+ <div class="group-text flex-grow title namespace-title prepend-left-default">
+ {{ envName }}
+ </div>
+ </div>
+ <ul v-if="isOpen" class="content-list group-list-tree">
+ <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" />
+ </ul>
+ </li>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 2b1c21f041b..4f89ad69129 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,13 +1,11 @@
<script>
import PodBox from './pod_box.vue';
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import Icon from '~/vue_shared/components/icon.vue';
+import Url from './url.vue';
export default {
components: {
- Icon,
PodBox,
- ClipboardButton,
+ Url,
},
props: {
func: {
@@ -36,24 +34,9 @@ export default {
<section id="serverless-function-details">
<h3>{{ name }}</h3>
<div class="append-bottom-default">
- <div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div>
- </div>
- <div class="clipboard-group append-bottom-default">
- <div class="label label-monospace">{{ funcUrl }}</div>
- <clipboard-button
- :text="String(funcUrl)"
- :title="s__('ServerlessDetails|Copy URL to clipboard')"
- class="input-group-text js-clipboard-btn"
- />
- <a
- :href="funcUrl"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="input-group-text btn btn-default"
- >
- <icon name="external-link" />
- </a>
+ <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
+ <url :uri="funcUrl" />
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0">
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 44bfae388cb..773d18781fd 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,9 +1,12 @@
<script>
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import Url from './url.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
export default {
components: {
Timeago,
+ Url,
},
props: {
func: {
@@ -16,13 +19,18 @@ export default {
return this.func.name;
},
description() {
- return this.func.description;
+ const desc = this.func.description.split('\n');
+ if (desc.length > 1) {
+ return desc[1];
+ }
+
+ return desc[0];
},
detailUrl() {
return this.func.detail_url;
},
- environment() {
- return this.func.environment_scope;
+ targetUrl() {
+ return this.func.url;
},
image() {
return this.func.image;
@@ -31,25 +39,34 @@ export default {
return this.func.created_at;
},
},
+ methods: {
+ checkClass(element) {
+ if (element.closest('.no-expand') === null) {
+ return true;
+ }
+
+ return false;
+ },
+ openDetails(e) {
+ if (this.checkClass(e.target)) {
+ visitUrl(this.detailUrl);
+ }
+ },
+ },
};
</script>
<template>
- <div class="gl-responsive-table-row">
- <div class="table-section section-20 section-wrap">
- <a :href="detailUrl">{{ name }}</a>
- </div>
- <div class="table-section section-10">{{ environment }}</div>
- <div class="table-section section-40 section-wrap">
- <span class="line-break">{{ description }}</span>
+ <li :id="name" class="group-row">
+ <div class="group-row-contents" role="button" @click="openDetails">
+ <p class="float-right text-right">
+ <span>{{ image }}</span
+ ><br />
+ <timeago :time="timestamp" />
+ </p>
+ <b>{{ name }}</b>
+ <div v-for="line in description.split('\n')" :key="line">{{ line }}</div>
+ <url :uri="targetUrl" class="prepend-top-8 no-expand" />
</div>
- <div class="table-section section-20">{{ image }}</div>
- <div class="table-section section-10"><timeago :time="timestamp" /></div>
- </div>
+ </li>
</template>
-
-<style>
-.line-break {
- white-space: pre;
-}
-</style>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 9606a78410e..4bde409f906 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,19 +1,21 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
+import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
export default {
components: {
+ EnvironmentRow,
FunctionRow,
EmptyState,
GlSkeletonLoading,
},
props: {
functions: {
- type: Array,
+ type: Object,
required: true,
- default: () => [],
+ default: () => ({}),
},
installed: {
type: Boolean,
@@ -45,33 +47,21 @@ export default {
<section id="serverless-functions">
<div v-if="installed">
<div v-if="hasFunctionData">
- <div class="ci-table js-services-list function-element">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-20" role="rowheader">
- {{ s__('Serverless|Function') }}
- </div>
- <div class="table-section section-10" role="rowheader">
- {{ s__('Serverless|Cluster Env') }}
- </div>
- <div class="table-section section-40" role="rowheader">
- {{ s__('Serverless|Description') }}
- </div>
- <div class="table-section section-20" role="rowheader">
- {{ s__('Serverless|Runtime') }}
- </div>
- <div class="table-section section-10" role="rowheader">
- {{ s__('Serverless|Last Update') }}
- </div>
+ <template v-if="loadingData">
+ <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
+ </template>
+ <template v-else>
+ <div class="groups-list-tree-container">
+ <ul class="content-list group-list-tree">
+ <environment-row
+ v-for="(env, index) in functions"
+ :key="index"
+ :env="env"
+ :env-name="index"
+ />
+ </ul>
</div>
- <template v-if="loadingData">
- <div v-for="j in 3" :key="j" class="gl-responsive-table-row">
- <gl-skeleton-loading />
- </div>
- </template>
- <template v-else>
- <function-row v-for="f in functions" :key="f.name" :func="f" />
- </template>
- </div>
+ </template>
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
@@ -111,16 +101,3 @@ export default {
<empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
</section>
</template>
-
-<style>
-.top-area {
- border-bottom: 0;
-}
-
-.function-element {
- border-bottom: 1px solid #e5e5e5;
- border-bottom-color: rgb(229, 229, 229);
- border-bottom-style: solid;
- border-bottom-width: 1px;
-}
-</style>
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
new file mode 100644
index 00000000000..ca53bf6c52a
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ ClipboardButton,
+ },
+ props: {
+ uri: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="clipboard-group">
+ <div class="url-text-field label label-monospace">{{ uri }}</div>
+ <clipboard-button
+ :text="uri"
+ :title="s__('ServerlessURL|Copy URL to clipboard')"
+ class="input-group-text js-clipboard-btn"
+ />
+ <gl-button
+ :href="uri"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="input-group-text btn btn-default"
+ >
+ <icon name="external-link" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
index 774c15b5b12..816d55a03f9 100644
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ b/app/assets/javascripts/serverless/stores/serverless_store.js
@@ -1,7 +1,7 @@
export default class ServerlessStore {
constructor(knativeInstalled = false, clustersPath, helpPath) {
this.state = {
- functions: [],
+ functions: {},
hasFunctionData: true,
loadingData: true,
installed: knativeInstalled,
@@ -10,8 +10,13 @@ export default class ServerlessStore {
};
}
- updateFunctionsFromServer(functions = []) {
- this.state.functions = functions;
+ updateFunctionsFromServer(upstreamFunctions = []) {
+ this.state.functions = upstreamFunctions.reduce((rv, func) => {
+ const envs = rv;
+ envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
+
+ return envs;
+ }, {});
}
updateLoadingState(loadingData) {
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index edefb3735d7..5172a1ef3d6 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import 'deckar01-task_list';
+import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
@@ -8,46 +9,79 @@ export default class TaskList {
this.selector = options.selector;
this.dataType = options.dataType;
this.fieldName = options.fieldName;
+ this.lockVersion = options.lockVersion;
+ this.taskListContainerSelector = `${this.selector} .js-task-list-container`;
+ this.updateHandler = this.update.bind(this);
this.onSuccess = options.onSuccess || (() => {});
- this.onError = function showFlash(e) {
- let errorMessages = '';
+ this.onError =
+ options.onError ||
+ function showFlash(e) {
+ let errorMessages = '';
- if (e.response.data && typeof e.response.data === 'object') {
- errorMessages = e.response.data.errors.join(' ');
- }
+ if (e.response.data && typeof e.response.data === 'object') {
+ errorMessages = e.response.data.errors.join(' ');
+ }
- return new Flash(errorMessages || 'Update failed', 'alert');
- };
+ return new Flash(errorMessages || __('Update failed'), 'alert');
+ };
this.init();
}
init() {
- // Prevent duplicate event bindings
- this.disable();
- $(`${this.selector} .js-task-list-container`).taskList('enable');
- $(document).on(
- 'tasklist:changed',
- `${this.selector} .js-task-list-container`,
- this.update.bind(this),
- );
+ this.disable(); // Prevent duplicate event bindings
+
+ $(this.taskListContainerSelector).taskList('enable');
+ $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
+ }
+
+ getTaskListTarget(e) {
+ return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector);
+ }
+
+ disableTaskListItems(e) {
+ this.getTaskListTarget(e).taskList('disable');
+ }
+
+ enableTaskListItems(e) {
+ this.getTaskListTarget(e).taskList('enable');
}
disable() {
- $(`${this.selector} .js-task-list-container`).taskList('disable');
- $(document).off('tasklist:changed', `${this.selector} .js-task-list-container`);
+ this.disableTaskListItems();
+ $(document).off('tasklist:changed', this.taskListContainerSelector);
}
update(e) {
const $target = $(e.target);
+ const { index, checked, lineNumber, lineSource } = e.detail;
const patchData = {};
+
patchData[this.dataType] = {
[this.fieldName]: $target.val(),
+ lock_version: this.lockVersion,
+ update_task: {
+ index,
+ checked,
+ line_number: lineNumber,
+ line_source: lineSource,
+ },
};
+ this.disableTaskListItems(e);
+
return axios
.patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData)
- .then(({ data }) => this.onSuccess(data))
- .catch(err => this.onError(err));
+ .then(({ data }) => {
+ this.lockVersion = data.lock_version;
+ this.enableTaskListItems(e);
+
+ return this.onSuccess(data);
+ })
+ .catch(({ response }) => {
+ this.enableTaskListItems(e);
+
+ return this.onError(response.data);
+ });
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 0ce9d271845..57c4dfbe3b7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -142,8 +142,8 @@ export default {
}
},
methods: {
- createService(store) {
- const endpoints = {
+ getServiceEndpoints(store) {
+ return {
mergePath: store.mergePath,
mergeCheckPath: store.mergeCheckPath,
cancelAutoMergePath: store.cancelAutoMergePath,
@@ -154,7 +154,9 @@ export default {
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
};
- return new MRWidgetService(endpoints);
+ },
+ createService(store) {
+ return new MRWidgetService(this.getServiceEndpoints(store));
},
checkStatus(cb, isRebased) {
return this.service
diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 0b0cd7b75eb..b57455adaad 100644
--- a/app/assets/javascripts/ide/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -1,45 +1,62 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
import Item from './item.vue';
-import router from '../../ide_router';
-import {
- MAX_FILE_FINDER_RESULTS,
- FILE_FINDER_ROW_HEIGHT,
- FILE_FINDER_EMPTY_ROW_HEIGHT,
-} from '../../constants';
-import {
- UP_KEY_CODE,
- DOWN_KEY_CODE,
- ENTER_KEY_CODE,
- ESC_KEY_CODE,
-} from '../../../lib/utils/keycodes';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+
+export const MAX_FILE_FINDER_RESULTS = 40;
+export const FILE_FINDER_ROW_HEIGHT = 55;
+export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
+
+const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
Item,
VirtualList,
},
+ props: {
+ files: {
+ type: Array,
+ required: true,
+ },
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ showDiffStats: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clearSearchOnClose: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
data() {
return {
- focusedIndex: 0,
+ focusedIndex: -1,
searchText: '',
mouseOver: false,
cancelMouseOver: false,
};
},
computed: {
- ...mapGetters(['allBlobs']),
- ...mapState(['fileFindVisible', 'loading']),
filteredBlobs() {
const searchText = this.searchText.trim();
if (searchText === '') {
- return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS);
+ return this.files.slice(0, MAX_FILE_FINDER_RESULTS);
}
- return fuzzaldrinPlus.filter(this.allBlobs, searchText, {
+ return fuzzaldrinPlus.filter(this.files, searchText, {
key: 'path',
maxResults: MAX_FILE_FINDER_RESULTS,
});
@@ -58,10 +75,12 @@ export default {
},
},
watch: {
- fileFindVisible() {
+ visible() {
this.$nextTick(() => {
- if (!this.fileFindVisible) {
- this.searchText = '';
+ if (!this.visible) {
+ if (this.clearSearchOnClose) {
+ this.searchText = '';
+ }
} else {
this.focusedIndex = 0;
@@ -72,7 +91,11 @@ export default {
});
},
searchText() {
- this.focusedIndex = 0;
+ this.focusedIndex = -1;
+
+ this.$nextTick(() => {
+ this.focusedIndex = 0;
+ });
},
focusedIndex() {
if (!this.mouseOver) {
@@ -98,8 +121,25 @@ export default {
}
},
},
+ mounted() {
+ if (this.files.length) {
+ this.focusedIndex = 0;
+ }
+
+ Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
+
+ this.toggle(!this.visible);
+ });
+
+ Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
+ },
methods: {
- ...mapActions(['toggleFileFinder']),
+ toggle(visible) {
+ this.$emit('toggle', visible);
+ },
clearSearchInput() {
this.searchText = '';
@@ -139,15 +179,15 @@ export default {
this.openFile(this.filteredBlobs[this.focusedIndex]);
break;
case ESC_KEY_CODE:
- this.toggleFileFinder(false);
+ this.toggle(false);
break;
default:
break;
}
},
openFile(file) {
- this.toggleFileFinder(false);
- router.push(`/project${file.url}`);
+ this.toggle(false);
+ this.$emit('click', file);
},
onMouseOver(index) {
if (!this.cancelMouseOver) {
@@ -159,14 +199,26 @@ export default {
this.cancelMouseOver = false;
this.onMouseOver(index);
},
+ mousetrapStopCallback(e, el, combo) {
+ if (
+ (combo === 't' && el.classList.contains('dropdown-input-field')) ||
+ el.classList.contains('inputarea')
+ ) {
+ return true;
+ } else if (combo === 'command+p' || combo === 'ctrl+p') {
+ return false;
+ }
+
+ return originalStopCallback(e, el, combo);
+ },
},
};
</script>
<template>
- <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false)">
- <div class="dropdown-menu diff-file-changes ide-file-finder show">
- <div class="dropdown-input">
+ <div class="file-finder-overlay" @mousedown.self="toggle(false)">
+ <div class="dropdown-menu diff-file-changes file-finder show">
+ <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input">
<input
ref="searchInput"
v-model="searchText"
@@ -186,9 +238,6 @@ export default {
></i>
<i
:aria-label="__('Clear search input')"
- :class="{
- show: showClearInputButton,
- }"
role="button"
class="fa fa-times dropdown-input-clear"
@click="clearSearchInput"
@@ -203,6 +252,7 @@ export default {
:search-text="searchText"
:focused="index === focusedIndex"
:index="index"
+ :show-diff-stats="showDiffStats"
class="disable-hover"
@click="openFile"
@mouseover="onMouseOver"
@@ -225,3 +275,25 @@ export default {
</div>
</div>
</template>
+
+<style scoped>
+.file-finder-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 200;
+}
+
+.file-finder {
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.diff-file-changes {
+ top: 50px;
+ max-height: 327px;
+}
+</style>
diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 83e80d50aff..73511879ff2 100644
--- a/app/assets/javascripts/ide/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -1,5 +1,6 @@
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '../../../vue_shared/components/file_icon.vue';
import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue';
@@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60;
export default {
components: {
+ Icon,
ChangedFileIcon,
FileIcon,
},
@@ -27,6 +29,11 @@ export default {
type: Number,
required: true,
},
+ showDiffStats: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pathWithEllipsis() {
@@ -97,8 +104,23 @@ export default {
</span>
</span>
</span>
- <span v-if="file.changed || file.tempFile" class="diff-changed-stats">
- <changed-file-icon :file="file" />
+ <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats">
+ <span v-if="showDiffStats">
+ <span class="cgreen bold">
+ <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }}
+ </span>
+ <span class="cred bold ml-1">
+ <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }}
+ </span>
+ </span>
+ <changed-file-icon v-else :file="file" />
</span>
</button>
</template>
+
+<style scoped>
+.highlighted {
+ color: #1f78d1;
+ font-weight: 600;
+}
+</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index cc07ef46064..3f607aa2a0a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -27,11 +27,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -158,7 +153,7 @@ export default {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
this.$http
- .post(this.versionedPreviewPath(), { text })
+ .post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.catch(() => new Flash(__('Error loading markdown preview')));
@@ -186,13 +181,6 @@ export default {
.then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() => new Flash(__('Error rendering markdown preview')));
},
-
- versionedPreviewPath() {
- const { markdownPreviewPath, markdownVersion } = this;
- return `${markdownPreviewPath}${
- markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
- }markdown_version=${markdownVersion}`;
- },
},
};
</script>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 0fb9bde1785..cb449b642e7 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -89,6 +89,10 @@ hr {
.str-truncated {
@include str-truncated;
+ &-30 {
+ @include str-truncated(30%);
+ }
+
&-60 {
@include str-truncated(60%);
}
@@ -390,9 +394,12 @@ img.emoji {
/** COMMON SIZING CLASSES **/
.w-0 { width: 0; }
-.h-13em { height: 13em; }
+
+.h-12em { height: 12em; }
+
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
+
.min-height-0 { min-height: 0; }
/** COMMON SPACING CLASSES **/
@@ -420,3 +427,9 @@ img.emoji {
.ms-no-clear ::-ms-clear {
display: none;
}
+
+/** COMMON POSITIONING CLASSES */
+.position-bottom-0 { bottom: 0; }
+.position-left-0 { left: 0; }
+.position-right-0 { right: 0; }
+.position-top-0 { top: 0; }
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index a20920e2503..d78c707192f 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -38,7 +38,10 @@
svg {
fill: currentColor;
+}
+.square,
+svg {
$svg-sizes: 8 10 12 14 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 1f24b8dfa9e..2ac98b5d18f 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -816,26 +816,6 @@ $ide-commit-header-height: 48px;
z-index: 1;
}
-.ide-file-finder-overlay {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: 100;
-}
-
-.ide-file-finder {
- top: 10px;
- left: 50%;
- transform: translateX(-50%);
-
- .highlighted {
- color: $blue-500;
- font-weight: $gl-font-weight-bold;
- }
-}
-
.ide-commit-message-field {
height: 200px;
background-color: $white-light;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index b6abb792709..61ecf133b02 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -240,18 +240,7 @@
}
.prometheus-graph {
- flex: 1 0 auto;
- min-width: 450px;
- max-width: 100%;
padding: $gl-padding / 2;
-
- h5 {
- font-size: 16px;
- }
-
- @include media-breakpoint-down(sm) {
- min-width: 100%;
- }
}
.prometheus-graph-header {
@@ -261,6 +250,7 @@
margin-bottom: $gl-padding-8;
h5 {
+ font-size: $gl-font-size-large;
margin: 0;
}
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 53afb182b54..38a7e199c6a 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -986,3 +986,9 @@
width: $ci-action-icon-size-lg;
}
}
+
+.merge-request-details .file-finder-overlay.diff-file-finder {
+ position: fixed;
+ z-index: 99999;
+ background: $black-transparent;
+}
diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss
new file mode 100644
index 00000000000..a5b73492380
--- /dev/null
+++ b/app/assets/stylesheets/pages/serverless.scss
@@ -0,0 +1,3 @@
+.url-text-field {
+ cursor: text;
+}
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 3d64ae8b775..cd3fa641e89 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -7,6 +7,9 @@ module IssuableActions
included do
before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
+ before_action only: :show do
+ push_frontend_feature_flag(:reply_to_individual_notes)
+ end
end
def permitted_keys
@@ -58,7 +61,8 @@ module IssuableActions
title_text: issuable.title,
description: view_context.markdown_field(issuable, :description),
description_text: issuable.description,
- task_status: issuable.task_status
+ task_status: issuable.task_status,
+ lock_version: issuable.lock_version
}
if issuable.edited?
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index 4b0f0b8255c..f72d25fc54c 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -16,8 +16,6 @@ module PreviewMarkdown
else {}
end
- markdown_params[:markdown_engine] = result[:markdown_engine]
-
render json: {
body: view_context.markdown(result[:text], markdown_params),
references: {
diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb
new file mode 100644
index 00000000000..372c803278d
--- /dev/null
+++ b/app/controllers/concerns/record_user_last_activity.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# == RecordUserLastActivity
+#
+# Controller concern that updates the `last_activity_on` field of `users`
+# for any authenticated GET request. The DB update will only happen once per day.
+#
+# In order to determine if you should include this concern or not, please check the
+# description and discussion on this issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/54947
+module RecordUserLastActivity
+ include CookiesHelper
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :set_user_last_activity
+ end
+
+ def set_user_last_activity
+ return unless request.get?
+ return unless Feature.enabled?(:set_user_last_activity, default_enabled: true)
+ return if Gitlab::Database.read_only?
+
+ if current_user && current_user.last_activity_on != Date.today
+ Users::ActivityService.new(current_user, "visited #{request.path}").execute
+ end
+ end
+end
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index 515a9eede8e..9ca54c5519b 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -3,16 +3,19 @@
module SendFileUpload
def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment')
if attachment
+ response_disposition = ::Gitlab::ContentDisposition.format(disposition: 'attachment', filename: attachment)
+
# Response-Content-Type will not override an existing Content-Type in
# Google Cloud Storage, so the metadata needs to be cleared on GCS for
# this to work. However, this override works with AWS.
- redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}",
+ redirect_params[:query] = { "response-content-disposition" => response_disposition,
"response-content-type" => guess_content_type(attachment) }
# By default, Rails will send uploads with an extension of .js with a
# content-type of text/javascript, which will trigger Rails'
# cross-origin JavaScript protection.
send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js'
- send_params.merge!(filename: attachment, disposition: disposition)
+
+ send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment))
end
if file_upload.file_storage?
@@ -25,6 +28,18 @@ module SendFileUpload
end
end
+ # Since Rails 5 doesn't properly support support non-ASCII filenames,
+ # we have to add our own to ensure RFC 5987 compliance. However, Rails
+ # 5 automatically appends `filename#{filename}` here:
+ # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137
+ # Rails 6 will have https://github.com/rails/rails/pull/33829, so we
+ # can get rid of this special case handling when we upgrade.
+ def utf8_encoded_disposition(disposition, filename)
+ content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename)
+
+ "#{disposition}; #{content.utf8_filename}"
+ end
+
def guess_content_type(filename)
types = MIME::Types.type_for(filename)
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index c6ae4fe15bf..48451bedcc2 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -72,7 +72,7 @@ module ServiceParams
dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables
service_params = params.permit(:id, service: allowed_service_params + dynamic_params)
- if service_params[:service].is_a?(Hash)
+ if service_params[:service].is_a?(ActionController::Parameters)
FILTER_BLANK_PARAMS.each do |param|
service_params[:service].delete(param) if service_params[:service][param].blank?
end
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index cee0753a021..0e9fdc60363 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -2,6 +2,7 @@
class Dashboard::ApplicationController < ApplicationController
include ControllerWithCrossProjectAccessCheck
+ include RecordUserLastActivity
layout 'dashboard'
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index cdc6f53df8e..51fdb6c05fb 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -2,6 +2,7 @@
class Groups::BoardsController < Groups::ApplicationController
include BoardsResponses
+ include RecordUserLastActivity
before_action :assign_endpoint_vars
before_action :boards, only: :index
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 15aadf3f74b..4e50106398a 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -5,6 +5,7 @@ class GroupsController < Groups::ApplicationController
include IssuableCollectionsAction
include ParamsBackwardCompatibility
include PreviewMarkdown
+ include RecordUserLastActivity
respond_to :html
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index f8e482937d5..97120273d6b 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -4,7 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
include Devise::Controllers::Rememberable
- protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true
+ protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
def handle_omniauth
omniauth_flow(Gitlab::Auth::OAuth)
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 1a1b024d766..79685e8b675 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -158,6 +158,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def search
+ respond_to do |format|
+ format.json do
+ environment_names = search_environment_names
+
+ render json: environment_names, status: environment_names.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
@@ -181,12 +191,18 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def search_environment_names
+ return [] unless params[:query]
+
+ project.environments.for_name_like(params[:query]).pluck_names
+ end
+
def serialize_environments(request, response, nested = false)
- serializer = EnvironmentSerializer
+ EnvironmentSerializer
.new(project: @project, current_user: @current_user)
+ .tap { |serializer| serializer.within_folders if nested }
.with_pagination(request, response)
- serializer = serializer.within_folders if nested
- serializer.represent(@environments)
+ .represent(@environments)
end
def authorize_stop_environment!
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 69f983f7ccd..b9d02a62fc3 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -8,6 +8,7 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableCollections
include IssuesCalendar
include SpammableActions
+ include RecordUserLastActivity
def self.issue_except_actions
%i[index calendar new create bulk_update import_csv]
@@ -246,7 +247,7 @@ class Projects::IssuesController < Projects::ApplicationController
task_num
lock_version
discussion_locked
- ] + [{ label_ids: [], assignee_ids: [] }]
+ ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }]
end
def store_uri
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index bc0a3d3526d..7c4dc95529a 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -7,6 +7,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include RendersCommits
include ToggleAwardEmoji
include IssuableCollections
+ include RecordUserLastActivity
skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index d3af35723ac..33c6608d321 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -6,6 +6,7 @@ class ProjectsController < Projects::ApplicationController
include ExtractsPath
include PreviewMarkdown
include SendFileUpload
+ include RecordUserLastActivity
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 1a69ec85d18..3e08c0ccd8f 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -18,6 +18,7 @@
# assignee_id: integer or 'None' or 'Any'
# assignee_username: string
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# non_archived: boolean
@@ -56,6 +57,7 @@ class IssuableFinder
milestone_title
my_reaction_emoji
search
+ in
]
end
@@ -408,7 +410,7 @@ class IssuableFinder
items = klass.with(cte.to_arel).from(klass.table_name)
end
- items.full_search(search)
+ items.full_search(search, matched_columns: params[:in])
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 45e494725d7..a0504ca0879 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -14,6 +14,7 @@
# milestone_title: string
# assignee_id: integer
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# my_reaction_emoji: string
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index e190d5d90c9..b645011a3c5 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -15,6 +15,7 @@
# author_id: integer
# assignee_id: integer
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# non_archived: boolean
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 4ab3c13787a..95e66fb3b7c 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -4,6 +4,10 @@ module Resolvers
class IssuesResolver < BaseResolver
extend ActiveSupport::Concern
+ argument :iids, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The list of IIDs of issues, e.g., [1, 2]'
+
argument :search, GraphQL::STRING_TYPE,
required: false
argument :sort, Types::Sort,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 01a0fb34484..2b1d6f49878 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -16,6 +16,13 @@ module AuthHelper
PROVIDERS_WITH_ICONS.include?(name.to_s)
end
+ def qa_class_for_provider(provider)
+ {
+ saml: 'qa-saml-login-button',
+ github: 'qa-github-login-button'
+ }[provider.to_sym]
+ end
+
def auth_providers
Gitlab::Auth::OAuth::Provider.providers
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index f8176facce9..1a471034972 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -268,7 +268,7 @@ module IssuablesHelper
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
- markdownVersion: issuable.cached_markdown_version,
+ lockVersion: issuable.lock_version,
issuableTemplates: issuable_templates(issuable),
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 0d638b850b4..66f4b7b3f30 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -116,7 +116,6 @@ module MarkupHelper
def markup(file_name, text, context = {})
context[:project] ||= @project
- context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled?
html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
prepare_for_rendering(html, context)
end
@@ -132,7 +131,6 @@ module MarkupHelper
page_slug: wiki_page.slug,
issuable_state_filter_enabled: true
)
- context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled?
html =
case wiki_page.format
@@ -187,10 +185,6 @@ module MarkupHelper
end
end
- def commonmark_for_repositories_enabled?
- Feature.enabled?(:commonmark_for_repositories, default_enabled: true)
- end
-
private
# Return +text+, truncated to +max_chars+ characters, excluding any HTML
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 293dd20ad49..aaf38cbfe70 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -171,7 +171,6 @@ module NotesHelper
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'),
- markdownVersion: issuable.cached_markdown_version,
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable),
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index df318de740a..5a42e581867 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -25,4 +25,8 @@ module ProfilesHelper
end
end
end
+
+ def user_profile?
+ params[:controller] == 'users'
+ end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 85248a16f50..c400302cda3 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -265,10 +265,6 @@ module ProjectsHelper
link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer'
end
- def legacy_render_context(params)
- params[:legacy_render] ? { markdown_engine: :redcarpet } : {}
- end
-
def explore_projects_tab?
current_page?(explore_projects_path) ||
current_page?(trending_explore_projects_path) ||
@@ -328,7 +324,7 @@ module ProjectsHelper
def external_nav_tabs(project)
[].tap do |tabs|
tabs << :external_issue_tracker if project.external_issue_tracker
- tabs << :external_wiki if project.has_external_wiki?
+ tabs << :external_wiki if project.external_wiki
end
end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 29696ab276f..c4e310e638d 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -6,4 +6,12 @@ class ApplicationRecord < ActiveRecord::Base
def self.id_in(ids)
where(id: ids)
end
+
+ def self.safe_find_or_create_by(*args)
+ transaction(requires_new: true) do
+ find_or_create_by(*args)
+ end
+ rescue ActiveRecord::RecordNotUnique
+ retry
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 84010e40ef4..6b2b7e77180 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -48,13 +48,23 @@ module Ci
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
##
- # The "environment" field for builds is a String, and is the unexpanded name!
+ # Since Gitlab 11.5, deployments records started being created right after
+ # `ci_builds` creation. We can look up a relevant `environment` through
+ # `deployment` relation today. This is much more efficient than expanding
+ # environment name with variables.
+ # (See more https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22380)
#
+ # However, we have to still expand environment name if it's a stop action,
+ # because `deployment` persists information for start action only.
+ #
+ # We will follow up this by persisting expanded name in build metadata or
+ # persisting stop action in database.
def persisted_environment
return unless has_environment?
strong_memoize(:persisted_environment) do
- Environment.find_by(name: expanded_environment_name, project: project)
+ deployment&.environment ||
+ Environment.find_by(name: expanded_environment_name, project: project)
end
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 002f3e17891..5fa6f79bdaa 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -13,7 +13,6 @@ module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
- CACHE_REDCARPET_VERSION = 3
CACHE_COMMONMARK_VERSION_START = 10
CACHE_COMMONMARK_VERSION = 14
@@ -42,18 +41,6 @@ module CacheMarkdownField
end
end
- class MarkdownEngine
- def self.from_version(version = nil)
- return :common_mark if version.nil? || version == 0
-
- if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
- :redcarpet
- else
- :common_mark
- end
- end
- end
-
def skip_project_check?
false
end
@@ -71,7 +58,7 @@ module CacheMarkdownField
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
- context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version)
+ context[:markdown_engine] = :common_mark
context
end
@@ -128,13 +115,7 @@ module CacheMarkdownField
end
def latest_cached_markdown_version
- return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version
-
- if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
- CacheMarkdownField::CACHE_REDCARPET_VERSION
- else
- CacheMarkdownField::CACHE_COMMONMARK_VERSION
- end
+ CacheMarkdownField::CACHE_COMMONMARK_VERSION
end
included do
@@ -178,7 +159,9 @@ module CacheMarkdownField
# author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do
changed_fields = changed_attributes.keys
- invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
+
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 0d363ec68b7..0a77fbeba08 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -136,10 +136,18 @@ module Issuable
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
+ # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
#
# Returns an ActiveRecord::Relation.
- def full_search(query)
- fuzzy_search(query, [:title, :description])
+ def full_search(query, matched_columns: 'title,description')
+ allowed_columns = [:title, :description]
+ matched_columns = matched_columns.to_s.split(',').map(&:to_sym)
+ matched_columns &= allowed_columns
+
+ # Matching title or description if the matched_columns did not contain any allowed columns.
+ matched_columns = [:title, :description] if matched_columns.empty?
+
+ fuzzy_search(query, matched_columns)
end
def sort_by_attribute(method, excluded_labels: [])
@@ -270,26 +278,29 @@ module Issuable
def to_hook_data(user, old_associations: {})
changes = previous_changes
- old_labels = old_associations.fetch(:labels, [])
- old_assignees = old_associations.fetch(:assignees, [])
- if old_labels != labels
- changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
- end
+ if old_associations
+ old_labels = old_associations.fetch(:labels, [])
+ old_assignees = old_associations.fetch(:assignees, [])
- if old_assignees != assignees
- if self.is_a?(Issue)
- changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
- else
- changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
+ if old_labels != labels
+ changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
end
- end
- if self.respond_to?(:total_time_spent)
- old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+ if old_assignees != assignees
+ if self.is_a?(Issue)
+ changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
+ else
+ changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
+ end
+ end
- if old_total_time_spent != total_time_spent
- changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ end
end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 29476654bf7..3c74034b527 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -1,9 +1,18 @@
# frozen_string_literal: true
module Noteable
- # Names of all implementers of `Noteable` that support resolvable notes.
+ extend ActiveSupport::Concern
+
+ # `Noteable` class names that support resolvable notes.
RESOLVABLE_TYPES = %w(MergeRequest).freeze
+ class_methods do
+ # `Noteable` class names that support replying to individual notes.
+ def replyable_types
+ %w(Issue MergeRequest)
+ end
+ end
+
def base_class_name
self.class.base_class.name
end
@@ -26,6 +35,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_replying_to_individual_notes?
+ supports_discussions? && self.class.replyable_types.include?(base_class_name)
+ end
+
def supports_suggestion?
false
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 603d4d62578..f147ce8ad6b 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -9,9 +9,11 @@ require 'task_list/filter'
#
# Used by MergeRequest and Issue
module Taskable
- COMPLETED = 'completed'.freeze
- INCOMPLETE = 'incomplete'.freeze
- ITEM_PATTERN = %r{
+ COMPLETED = 'completed'.freeze
+ INCOMPLETE = 'incomplete'.freeze
+ COMPLETE_PATTERN = /(\[[xX]\])/.freeze
+ INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze
+ ITEM_PATTERN = %r{
^
\s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
\s+ # whitespace prefix has to be always presented for a list item
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index dbc7b6e67be..f2678e0597d 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -17,6 +17,8 @@ class Discussion
:for_commit?,
:for_merge_request?,
+ :save,
+
to: :first_note
def project_id
@@ -116,6 +118,10 @@ class Discussion
false
end
+ def can_convert_to_discussion?
+ false
+ end
+
def new_discussion?
notes.length == 1
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index cdfe3b7c023..1fc088b12ae 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -50,6 +50,14 @@ class Environment < ActiveRecord::Base
end
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
+
+ ##
+ # Search environments which have names like the given query.
+ # Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
+ scope :for_name_like, -> (query, limit: 5) do
+ where('name LIKE ?', "#{sanitize_sql_like(query)}%").limit(limit)
+ end
+
scope :for_project, -> (project) { where(project_id: project) }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
@@ -70,6 +78,10 @@ class Environment < ActiveRecord::Base
end
end
+ def self.pluck_names
+ pluck(:name)
+ end
+
def predefined_variables
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 7f4947ba27a..31084c54bdc 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -8,9 +8,13 @@ module ErrorTracking
belongs_to :project
- validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true }
+ validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
- validate :validate_api_url_path
+ validates :api_url, presence: true, if: :enabled
+
+ validate :validate_api_url_path, if: :enabled
+
+ validates :token, presence: true, if: :enabled
attr_encrypted :token,
mode: :per_attribute_iv,
@@ -19,6 +23,31 @@ module ErrorTracking
after_save :clear_reactive_cache!
+ def project_name
+ super || project_name_from_slug
+ end
+
+ def organization_name
+ super || organization_name_from_slug
+ end
+
+ def project_slug
+ project_slug_from_api_url
+ end
+
+ def organization_slug
+ organization_slug_from_api_url
+ end
+
+ def self.build_api_url_from(api_host:, project_slug:, organization_slug:)
+ uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/")
+ uri.path = uri.path.squeeze('/')
+
+ uri.to_s
+ rescue Addressable::URI::InvalidURIError
+ api_host
+ end
+
def sentry_client
Sentry::Client.new(api_url, token)
end
@@ -33,6 +62,10 @@ module ErrorTracking
end
end
+ def list_sentry_projects
+ { projects: sentry_client.list_projects }
+ end
+
def calculate_reactive_cache(request, opts)
case request
when 'list_issues'
@@ -47,13 +80,53 @@ module ErrorTracking
url.sub('api/0/projects/', '')
end
+ def api_host
+ return if api_url.blank?
+
+ # This returns http://example.com/
+ Addressable::URI.join(api_url, '/').to_s
+ end
+
private
+ def project_name_from_slug
+ @project_name_from_slug ||= project_slug_from_api_url&.titleize
+ end
+
+ def organization_name_from_slug
+ @organization_name_from_slug ||= organization_slug_from_api_url&.titleize
+ end
+
+ def project_slug_from_api_url
+ extract_slug(:project)
+ end
+
+ def organization_slug_from_api_url
+ extract_slug(:organization)
+ end
+
+ def extract_slug(capture)
+ return if api_url.blank?
+
+ begin
+ url = Addressable::URI.parse(api_url)
+ rescue Addressable::URI::InvalidURIError
+ return nil
+ end
+
+ @slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {}
+ @slug_match[capture]
+ end
+
def validate_api_url_path
- unless URI(api_url).path.starts_with?('/api/0/projects')
- errors.add(:api_url, 'path needs to start with /api/0/projects')
+ return if api_url.blank?
+
+ begin
+ unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects')
+ errors.add(:api_url, 'path needs to start with /api/0/projects')
+ end
+ rescue Addressable::URI::InvalidURIError
end
- rescue URI::InvalidURIError
end
end
end
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
index 07ee7470ea2..aab0ff93468 100644
--- a/app/models/individual_note_discussion.rb
+++ b/app/models/individual_note_discussion.rb
@@ -13,6 +13,14 @@ class IndividualNoteDiscussion < Discussion
true
end
+ def can_convert_to_discussion?
+ noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes)
+ end
+
+ def convert_to_discussion!
+ first_note.becomes!(Discussion.note_class).to_discussion
+ end
+
def reply_attributes
super.tap { |attrs| attrs.delete(:discussion_id) }
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index a3029a54604..712347e76ed 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -7,6 +7,7 @@ class MergeRequestDiff < ActiveRecord::Base
include IgnorableColumn
include EachBatch
include Gitlab::Utils::StrongMemoize
+ include ObjectStorage::BackgroundMove
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
@@ -15,9 +16,13 @@ class MergeRequestDiff < ActiveRecord::Base
:st_diffs
belongs_to :merge_request
+
manual_inverse_association :merge_request, :merge_request_diff
- has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_files,
+ -> { order(:merge_request_diff_id, :relative_order) },
+ inverse_of: :merge_request_diff
+
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
state_machine :state, initial: :empty do
@@ -45,10 +50,14 @@ class MergeRequestDiff < ActiveRecord::Base
scope :recent, -> { order(id: :desc).limit(100) }
+ mount_uploader :external_diff, ExternalDiffUploader
+
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
+ after_save :update_external_diff_store, if: :external_diff_changed?
+
def self.find_by_diff_refs(diff_refs)
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
end
@@ -241,10 +250,97 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
+ # Carrierwave defines `write_uploader` dynamically on this class, so `super`
+ # does not work. Alias the carrierwave method so we can call it when needed
+ alias_method :carrierwave_write_uploader, :write_uploader
+
+ # The `external_diff`, `external_diff_store`, and `stored_externally`
+ # columns were introduced in GitLab 11.8, but some background migration specs
+ # use factories that rely on current code with an old schema. Without these
+ # `has_attribute?` guards, they fail with a `MissingAttributeError`.
+ #
+ # For more details, see: https://gitlab.com/gitlab-org/gitlab-ce/issues/44990
+
+ def write_uploader(column, identifier)
+ carrierwave_write_uploader(column, identifier) if has_attribute?(column)
+ end
+
+ def update_external_diff_store
+ update_column(:external_diff_store, external_diff.object_store) if
+ has_attribute?(:external_diff_store)
+ end
+
+ def external_diff_changed?
+ super if has_attribute?(:external_diff)
+ end
+
+ def stored_externally
+ super if has_attribute?(:stored_externally)
+ end
+ alias_method :stored_externally?, :stored_externally
+
+ # If enabled, yields the external file containing the diff. Otherwise, yields
+ # nil. This method is not thread-safe, but it *is* re-entrant, which allows
+ # multiple merge_request_diff_files to load their data efficiently
+ def opening_external_diff
+ return yield(nil) unless stored_externally?
+ return yield(@external_diff_file) if @external_diff_file
+
+ external_diff.open do |file|
+ begin
+ @external_diff_file = file
+
+ yield(@external_diff_file)
+ ensure
+ @external_diff_file = nil
+ end
+ end
+ end
+
private
def create_merge_request_diff_files(diffs)
- rows = diffs.map.with_index do |diff, index|
+ rows =
+ if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled
+ build_external_merge_request_diff_files(diffs)
+ else
+ build_merge_request_diff_files(diffs)
+ end
+
+ # Faster inserts
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ end
+
+ def build_external_merge_request_diff_files(diffs)
+ rows = build_merge_request_diff_files(diffs)
+ tempfile = build_external_diff_tempfile(rows)
+
+ self.external_diff = tempfile
+ self.stored_externally = true
+
+ rows
+ ensure
+ tempfile&.unlink
+ end
+
+ def build_external_diff_tempfile(rows)
+ Tempfile.open(external_diff.filename) do |file|
+ rows.inject(0) do |offset, row|
+ data = row.delete(:diff)
+ row[:external_diff_offset] = offset
+ row[:external_diff_size] = data.size
+
+ file.write(data)
+
+ offset + data.size
+ end
+
+ file
+ end
+ end
+
+ def build_merge_request_diff_files(diffs)
+ diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
binary: false,
merge_request_diff_id: self.id,
@@ -261,18 +357,20 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
end
-
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
end
def load_diffs(options)
- collection = merge_request_diff_files
+ # Ensure all diff files operate on the same external diff file instance if
+ # present. This reduces file open/close overhead.
+ opening_external_diff do
+ collection = merge_request_diff_files
- if paths = options[:paths]
- collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
- end
+ if paths = options[:paths]
+ collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
+ end
- Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ end
end
def load_commits
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index a9f110bec5c..e8d936e265c 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -4,7 +4,7 @@ class MergeRequestDiffFile < ActiveRecord::Base
include Gitlab::EncodingHelper
include DiffFile
- belongs_to :merge_request_diff
+ belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
def utf8_diff
return '' if diff.blank?
@@ -13,6 +13,16 @@ class MergeRequestDiffFile < ActiveRecord::Base
end
def diff
- binary? ? super.unpack('m0').first : super
+ content =
+ if merge_request_diff&.stored_externally?
+ merge_request_diff.opening_external_diff do |file|
+ file.seek(external_diff_offset)
+ file.read(external_diff_size)
+ end
+ else
+ super
+ end
+
+ binary? ? content.unpack('m0').first : content
end
end
diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb
index 0e667dac21e..5f0f313b7f9 100644
--- a/app/models/programming_language.rb
+++ b/app/models/programming_language.rb
@@ -3,4 +3,10 @@
class ProgrammingLanguage < ActiveRecord::Base
validates :name, presence: true
validates :color, allow_blank: false, color: true
+
+ # Returns all programming languages which match the given name (case
+ # insensitively).
+ scope :with_name_case_insensitive, ->(name) do
+ where(arel_table[:name].matches(sanitize_sql_like(name)))
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index b385b89449d..d4e2ed883bc 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -389,6 +389,16 @@ class Project < ActiveRecord::Base
with_project_feature.where(project_features: { access_level_attribute => level })
}
+ # Picks projects which use the given programming language
+ scope :with_programming_language, ->(language_name) do
+ lang_id_query = ProgrammingLanguage
+ .with_name_case_insensitive(language_name)
+ .select(:id)
+
+ joins(:repository_languages)
+ .where(repository_languages: { programming_language_id: lang_id_query })
+ end
+
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
@@ -738,11 +748,13 @@ class Project < ActiveRecord::Base
end
def import_url=(value)
- return super(value) unless Gitlab::UrlSanitizer.valid?(value)
-
- import_url = Gitlab::UrlSanitizer.new(value)
- super(import_url.sanitized_url)
- create_or_update_import_data(credentials: import_url.credentials)
+ if Gitlab::UrlSanitizer.valid?(value)
+ import_url = Gitlab::UrlSanitizer.new(value)
+ super(import_url.sanitized_url)
+ create_or_update_import_data(credentials: import_url.credentials)
+ else
+ super(value)
+ end
end
def import_url
@@ -1066,7 +1078,7 @@ class Project < ActiveRecord::Base
# rubocop: disable CodeReuse/ServiceClass
def create_labels
Label.templates.each do |label|
- params = label.attributes.except('id', 'template', 'created_at', 'updated_at')
+ params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type')
Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true)
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index b47238b52f1..9ae13fbaa80 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -525,6 +525,8 @@ class Repository
# items is an Array like: [[oid, path], [oid1, path1]]
def blobs_at(items)
+ return [] unless exists?
+
raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) }
end
@@ -612,7 +614,6 @@ class Repository
return unless readme
context = { project: project }
- context[:markdown_engine] = :redcarpet unless MarkupHelper.commonmark_for_repositories_enabled?
MarkupHelper.markup_unsafe(readme.name, readme.data, context)
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index e65b3df0fb6..6caab24143b 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -48,7 +48,7 @@ class SentNotification < ActiveRecord::Base
end
def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
- attrs[:in_reply_to_discussion_id] = note.discussion_id
+ attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion?
record(note.noteable, recipient_id, reply_key, attrs)
end
@@ -99,29 +99,12 @@ class SentNotification < ActiveRecord::Base
private
def reply_params
- attrs = {
+ {
noteable_type: self.noteable_type,
noteable_id: self.noteable_id,
- commit_id: self.commit_id
+ commit_id: self.commit_id,
+ in_reply_to_discussion_id: self.in_reply_to_discussion_id
}
-
- if self.in_reply_to_discussion_id.present?
- attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id
- else
- # Remove in GitLab 10.0, when we will not support replying to SentNotifications
- # that don't have `in_reply_to_discussion_id` anymore.
- attrs.merge!(
- type: self.note_type,
-
- # LegacyDiffNote
- line_code: self.line_code,
-
- # DiffNote
- position: self.position.to_json
- )
- end
-
- attrs
end
def note_valid
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index 99a0c54a26a..fd23cc9ac87 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -26,6 +26,7 @@ class SshHostKey
self.reactive_cache_lifetime = 10.minutes
def self.find_by(opts = {})
+ opts = HashWithIndifferentAccess.new(opts)
return nil unless opts.key?(:id)
project_id, url = opts[:id].split(':', 2)
@@ -54,7 +55,7 @@ class SshHostKey
# Needed for reactive caching
def self.primary_key
- 'id'
+ :id
end
def id
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index 084627f9dbe..178e72f4f0a 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -11,4 +11,5 @@ class MergeRequestBasicEntity < Grape::Entity
expose :labels, using: LabelEntity
expose :assignee, using: API::Entities::UserBasic
expose :task_status, :task_status_short
+ expose :lock_version, :lock_version
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index f8d8ef04001..699b3e8555e 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -7,14 +7,17 @@ module Ci
CreateError = Class.new(StandardError)
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
+ Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
Gitlab::Ci::Pipeline::Chain::Validate::Config,
Gitlab::Ci::Pipeline::Chain::Skip,
+ Gitlab::Ci::Pipeline::Chain::Limit::Size,
Gitlab::Ci::Pipeline::Chain::Populate,
- Gitlab::Ci::Pipeline::Chain::Create].freeze
+ Gitlab::Ci::Pipeline::Chain::Create,
+ Gitlab::Ci::Pipeline::Chain::Limit::Activity].freeze
- def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, **options, &block)
@pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -32,7 +35,8 @@ module Ci
variables_attributes: params[:variables_attributes],
project: project,
current_user: current_user,
- push_options: params[:push_options])
+ push_options: params[:push_options],
+ **extra_options(**options))
sequence = Gitlab::Ci::Pipeline::Chain::Sequence
.new(pipeline, command, SEQUENCE)
@@ -103,5 +107,9 @@ module Ci
pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def extra_options
+ {} # overriden in EE
+ end
end
end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index f54574b026b..4ba3f5fb8ba 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -7,6 +7,8 @@ module Ci
def execute
if trigger_from_token
create_pipeline_from_trigger(trigger_from_token)
+ elsif job_from_token
+ create_pipeline_from_job(job_from_token)
end
end
@@ -35,6 +37,14 @@ module Ci
end
end
+ def create_pipeline_from_job(job)
+ # overriden in EE
+ end
+
+ def job_from_token
+ # overriden in EE
+ end
+
def variables
params[:variables].to_h.map do |key, value|
{ key: key, value: value }
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 24d8400c625..55a3b9fa7b1 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -10,6 +10,8 @@ module Groups
def execute
@group = Group.new(params)
+ after_build_hook(@group, params)
+
unless can_use_visibility_level? && can_create_group?
return @group
end
@@ -30,6 +32,10 @@ module Groups
private
+ def after_build_hook(group, params)
+ # overriden in EE
+ end
+
def create_chat_team?
Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil?
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index de78a3f7b27..9ff1da270e2 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -11,6 +11,8 @@ module Groups
return false unless valid_share_with_group_lock_change?
+ before_assignment_hook(group, params)
+
group.assign_attributes(params)
begin
@@ -28,6 +30,10 @@ module Groups
private
+ def before_assignment_hook(group, params)
+ # overriden in EE
+ end
+
def after_update
if group.previous_changes.include?(:visibility_level) && group.private?
# don't enqueue immediately to prevent todos removal in case of a mistake
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 885e14bba8f..77f38f8882e 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -20,7 +20,7 @@ module Issuable
create_due_date_note if issuable.previous_changes.include?('due_date')
create_milestone_note if issuable.previous_changes.include?('milestone_id')
- create_labels_note(old_labels) if issuable.labels != old_labels
+ create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end
private
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 805bb5b510d..ef991eaf234 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -235,6 +235,61 @@ class IssuableBaseService < BaseService
issuable
end
+ def update_task(issuable)
+ filter_params(issuable)
+
+ if issuable.changed? || params.present?
+ issuable.assign_attributes(params.merge(updated_by: current_user,
+ last_edited_at: Time.now,
+ last_edited_by: current_user))
+
+ before_update(issuable)
+
+ if issuable.with_transaction_returning_status { issuable.save }
+ # We do not touch as it will affect a update on updated_at field
+ ActiveRecord::Base.no_touching do
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil)
+ end
+
+ handle_task_changes(issuable)
+ invalidate_cache_counts(issuable, users: issuable.assignees.to_a)
+ after_update(issuable)
+ execute_hooks(issuable, 'update', old_associations: nil)
+ end
+ end
+
+ issuable
+ end
+
+ # Handle the `update_task` event sent from UI. Attempts to update a specific
+ # line in the markdown and cached html, bypassing any unnecessary updates or checks.
+ def update_task_event(issuable)
+ update_task_params = params.delete(:update_task)
+ return unless update_task_params
+
+ tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html,
+ line_source: update_task_params[:line_source],
+ line_number: update_task_params[:line_number].to_i,
+ toggle_as_checked: update_task_params[:checked])
+
+ unless tasklist_toggler.execute
+ # if we make it here, the data is much newer than we thought it was - fail fast
+ raise ActiveRecord::StaleObjectError
+ end
+
+ # by updating the description_html field at the same time,
+ # the markdown cache won't be considered invalid
+ params[:description] = tasklist_toggler.updated_markdown
+ params[:description_html] = tasklist_toggler.updated_markdown_html
+
+ # since we're updating a very specific line, we don't care whether
+ # the `lock_version` sent from the FE is the same or not. Just
+ # make sure the data hasn't changed since we queried it
+ params[:lock_version] = issuable.lock_version
+
+ update_task(issuable)
+ end
+
def labels_changing?(old_label_ids, new_label_ids)
old_label_ids.sort != new_label_ids.sort
end
@@ -318,6 +373,10 @@ class IssuableBaseService < BaseService
end
# override if needed
+ def handle_task_changes(issuable)
+ end
+
+ # override if needed
def execute_hooks(issuable, action = 'open', params = {})
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index e992d682c79..cec5b5734c0 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -8,7 +8,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- move_issue_to_new_project(issue) || update(issue)
+ move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
@@ -63,6 +63,11 @@ module Issues
end
end
+ def handle_task_changes(issuable)
+ todo_service.mark_pending_todos_as_done(issuable, current_user)
+ todo_service.update_issue(issuable, current_user)
+ end
+
def handle_move_between_ids(issue)
return unless params[:move_between_ids]
@@ -78,6 +83,8 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord
def change_issue_duplicate(issue)
canonical_issue_id = params.delete(:canonical_issue_id)
+ return unless canonical_issue_id
+
canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id)
if canonical_issue
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 714b8586737..cf710fef52b 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -19,9 +19,19 @@ module Members
current_user: current_user
)
- members.each { |member| after_execute(member: member) }
+ errors = []
- success
+ members.each do |member|
+ if member.errors.any?
+ errors << "#{member.user.username}: #{member.errors.full_messages.to_sentence}"
+ else
+ after_execute(member: member)
+ end
+ end
+
+ return success unless errors.any?
+
+ error(errors.to_sentence)
end
private
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index fe19abf50f6..ac51fee0b3f 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -63,6 +63,7 @@ module MergeRequests
# UpdateMergeRequestsWorker could be retried by an exception.
# MR pipelines should not be recreated in such case.
return if merge_request.merge_request_pipeline_exists?
+ return if merge_request.has_no_commits?
Ci::CreatePipelineService
.new(merge_request.source_project, user, ref: merge_request.source_branch)
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index bae98ede561..541f3e0d23c 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -15,6 +15,8 @@ module Notes
return note
end
+ discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion?
+
params.merge!(discussion.reply_attributes)
should_resolve = discussion.resolved?
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index c4546f30235..b975c3a8cb6 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -34,6 +34,10 @@ module Notes
end
if !only_commands && note.save
+ if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
+ note.discussion.convert_to_discussion!.save(touch: false)
+ end
+
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
Suggestions::CreateService.new(note).execute
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index a449a5dc3e9..c1655c38095 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -10,8 +10,7 @@ class PreviewMarkdownService < BaseService
text: text,
users: users,
suggestions: suggestions,
- commands: commands.join(' '),
- markdown_engine: markdown_engine
+ commands: commands.join(' ')
)
end
@@ -49,12 +48,4 @@ class PreviewMarkdownService < BaseService
def commands_target_id
params[:quick_actions_target_id]
end
-
- def markdown_engine
- if params[:legacy_render]
- :redcarpet
- else
- CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i)
- end
- end
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index abf40b3ad7a..674071ad92a 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -2,6 +2,8 @@
module Projects
class UpdatePagesConfigurationService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
attr_reader :project
def initialize(project)
@@ -9,15 +11,25 @@ module Projects
end
def execute
- update_file(pages_config_file, pages_config.to_json)
+ if file_equals?(pages_config_file, pages_config_json)
+ return success(reload: false)
+ end
+
+ update_file(pages_config_file, pages_config_json)
reload_daemon
- success
+ success(reload: true)
rescue => e
error(e.message)
end
private
+ def pages_config_json
+ strong_memoize(:pages_config_json) do
+ pages_config.to_json
+ end
+ end
+
def pages_config
{
domains: pages_domains_config,
@@ -67,11 +79,6 @@ module Projects
end
def update_file(file, data)
- unless data
- FileUtils.remove(file, force: true)
- return
- end
-
temp_file = "#{file}.#{SecureRandom.hex(16)}"
File.open(temp_file, 'w') do |f|
f.write(data)
@@ -81,5 +88,18 @@ module Projects
# In case if the updating fails
FileUtils.remove(temp_file, force: true)
end
+
+ def file_equals?(file, data)
+ existing_data = read_file(file)
+ data == existing_data.to_s
+ end
+
+ def read_file(file)
+ File.open(file, 'r') do |f|
+ f.read
+ end
+ rescue
+ nil
+ end
end
end
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index cb1bf0a03a5..d6af26d949d 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -2,6 +2,8 @@
module Search
class GlobalService
+ include Gitlab::Utils::StrongMemoize
+
attr_accessor :current_user, :params
attr_reader :default_project_filter
@@ -19,11 +21,15 @@ module Search
@projects ||= ProjectsFinder.new(current_user: current_user).execute
end
- def scope
- @scope ||= begin
- allowed_scopes = %w[issues merge_requests milestones]
+ def allowed_scopes
+ strong_memoize(:allowed_scopes) do
+ %w[issues merge_requests milestones]
+ end
+ end
- allowed_scopes.delete(params[:scope]) { 'projects' }
+ def scope
+ strong_memoize(:scope) do
+ allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects'
end
end
end
diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb
new file mode 100644
index 00000000000..b5c4cd3235d
--- /dev/null
+++ b/app/services/task_list_toggle_service.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+# Finds the correct checkbox in the passed in markdown/html and toggles it's state,
+# returning the updated markdown/html.
+# We don't care if the text has changed above or below the specific checkbox, as long
+# the checkbox still exists at exactly the same line number and the text is equal.
+# If successful, new values are available in `updated_markdown` and `updated_markdown_html`
+class TaskListToggleService
+ attr_reader :updated_markdown, :updated_markdown_html
+
+ def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:)
+ @markdown, @markdown_html = markdown, markdown_html
+ @line_source, @line_number = line_source, line_number
+ @toggle_as_checked = toggle_as_checked
+
+ @updated_markdown, @updated_markdown_html = nil
+ end
+
+ def execute
+ return false unless markdown && markdown_html
+
+ toggle_markdown && toggle_markdown_html
+ end
+
+ private
+
+ attr_reader :markdown, :markdown_html, :toggle_as_checked
+ attr_reader :line_source, :line_number
+
+ def toggle_markdown
+ source_lines = markdown.split("\n")
+ source_line_index = line_number - 1
+ markdown_task = source_lines[source_line_index]
+
+ return unless markdown_task == line_source
+ return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task)
+
+ currently_checked = TaskList::Item.new(source_checkbox[1]).complete?
+
+ # Check `toggle_as_checked` to make sure we don't accidentally replace
+ # any `[ ]` or `[x]` in the middle of the text
+ if currently_checked
+ markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked
+ else
+ markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked
+ end
+
+ source_lines[source_line_index] = markdown_task
+ @updated_markdown = source_lines.join("\n")
+ end
+
+ def toggle_markdown_html
+ html = Nokogiri::HTML.fragment(markdown_html)
+ html_checkbox = get_html_checkbox(html)
+ return unless html_checkbox
+
+ if toggle_as_checked
+ html_checkbox[:checked] = 'checked'
+ else
+ html_checkbox.remove_attribute('checked')
+ end
+
+ @updated_markdown_html = html.to_html
+ end
+
+ # When using CommonMark, we should be able to use the embedded `sourcepos` attribute to
+ # target the exact line in the DOM.
+ def get_html_checkbox(html)
+ html.css(".task-list-item[data-sourcepos^='#{line_number}:'] > input.task-list-item-checkbox").first
+ end
+end
diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb
new file mode 100644
index 00000000000..d2707cd0777
--- /dev/null
+++ b/app/uploaders/external_diff_uploader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ExternalDiffUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.external_diffs
+
+ alias_method :upload, :model
+
+ def filename
+ "diff-#{model.id}"
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.model_name.plural, "mr-#{model.merge_request_id}")
+ end
+end
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index 544f09048f5..77e84abd76e 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -1,86 +1,96 @@
-= form_for @appearance, url: admin_appearances_path do |f|
+= form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f|
= form_errors(@appearance)
- %fieldset.app_logo
- %legend
- Navigation bar:
- .form-group.row
- = f.label :header_logo, 'Header logo', class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.header_logo?
- = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :header_logo_cache
- = f.file_field :header_logo, class: ""
- .hint
- Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
- %fieldset.app_logo
- %legend
- Favicon:
- .form-group.row
- = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.favicon?
- = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :favicon_cache
- = f.file_field :favicon, class: ''
- .hint
- Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
- %br
- Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Navigation bar
+
+ .col-lg-8
+ .form-group
+ = f.label :header_logo, 'Header logo', class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.header_logo?
+ = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :header_logo_cache
+ = f.file_field :header_logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Favicon
+
+ .col-lg-8
+ .form-group
+ = f.label :favicon, 'Favicon', class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.favicon?
+ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :favicon_cache
+ = f.file_field :favicon, class: ''
+ .hint
+ Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
+ %br
+ Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.
- %fieldset.sign-in
- %legend
- Sign in/Sign up pages:
- .form-group.row
- = f.label :title, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_field :title, class: "form-control"
- .form-group.row
- = f.label :description, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_area :description, class: "form-control", rows: 10
- .hint
- Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
- .form-group.row
- = f.label :logo, class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.logo?
- = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :logo_cache
- = f.file_field :logo, class: ""
- .hint
- Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
- %fieldset
- %legend
- New project pages:
- .form-group.row
- = f.label :new_project_guidelines, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_area :new_project_guidelines, class: "form-control", rows: 10
- .hint
- Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Sign in/Sign up pages
- .form-actions
- = f.submit 'Save', class: 'btn btn-success append-right-10'
- - if @appearance.persisted?
- Preview last save:
- = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ .col-lg-8
+ .form-group
+ = f.label :title, class: 'col-form-label label-bold'
+ = f.text_field :title, class: "form-control"
+ .form-group
+ = f.label :description, class: 'col-form-label label-bold'
+ = f.text_area :description, class: "form-control", rows: 10
+ .hint
+ Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ .form-group
+ = f.label :logo, class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.logo?
+ = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :logo_cache
+ = f.file_field :logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
+
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 New project pages
+
+ .col-lg-8
+ .form-group
+ = f.label :new_project_guidelines, class: 'col-form-label label-bold'
+ %p
+ = f.text_area :new_project_guidelines, class: "form-control", rows: 10
+ .hint
+ Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+
+ .prepend-top-default.append-bottom-default
+ = f.submit 'Update appearance settings', class: 'btn btn-success'
+ - if @appearance.persisted?
+ Preview last save:
+ = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- - if @appearance.updated_at
- %span.float-right
- Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
+ - if @appearance.updated_at
+ %span.float-right
+ Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml
index 454b779842c..ccf6f960cf2 100644
--- a/app/views/admin/appearances/show.html.haml
+++ b/app/views/admin/appearances/show.html.haml
@@ -1,9 +1,4 @@
- page_title "Appearance"
-
-%h3.page-title
- Appearance settings
-%p.light
- You can modify the look and feel of GitLab here
-%hr
+- @content_class = "limit-container-width" unless fluid_layout
= render 'form'
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 10bc3452d8b..65a24854583 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -15,7 +15,7 @@
= f.number_field :max_attachment_size, class: 'form-control'
.form-group
= f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light'
- = f.number_field :receive_max_input_size, class: 'form-control'
+ = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field'
.form-group
= f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control'
@@ -46,4 +46,4 @@
= f.label :user_show_add_ssh_key_message, class: 'form-check-label' do
Inform users without uploaded SSH keys that they can't push over SSH until one is added
- = f.submit 'Save changes', class: 'btn btn-success'
+ = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 65e4723afe6..fc9dd29b8ca 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -13,7 +13,7 @@
.settings-content
= render 'visibility_and_access'
-%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) }
+%section.settings.qa-account-and-limit-settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
= _('Account and limit')
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 796c0cadda8..f856773526d 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,3 +1,5 @@
+- server = local_assigns.fetch(:server)
+
= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
.form-group
= label_tag :username, "#{server['label']} Username"
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 12271ee5adb..1b583ea85d6 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -5,7 +5,7 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'btn d-flex align-items-center omniauth-btn text-left oauth-login qa-saml-login-button', id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
- if has_icon
= provider_image_tag(provider)
%span
diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml
index c4ae7befe4e..666dab61f40 100644
--- a/app/views/email_rejection_mailer/rejection.html.haml
+++ b/app/views/email_rejection_mailer/rejection.html.haml
@@ -1,5 +1,5 @@
%p
- Unfortunately, your email message to GitLab could not be processed.
+ = _("Unfortunately, your email message to GitLab could not be processed.")
= markdown @reason
= render_if_exists 'shared/additional_email_text'
diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml
index 0e13b2a6473..8d940ef1293 100644
--- a/app/views/email_rejection_mailer/rejection.text.haml
+++ b/app/views/email_rejection_mailer/rejection.text.haml
@@ -1,4 +1,4 @@
-Unfortunately, your email message to GitLab could not be processed.
+= _("Unfortunately, your email message to GitLab could not be processed.")
\
= @reason
= render_if_exists 'shared/additional_email_text'
diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml
index 6ae4c334f7f..e1b7804c5a7 100644
--- a/app/views/events/_events.html.haml
+++ b/app/views/events/_events.html.haml
@@ -1,4 +1,18 @@
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
+- visitor_empty_message = _('No activities found')
+
- if @events.present?
= render partial: 'events/event', collection: @events
- else
- .nothing-here-block= _("No activities found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
index 33a4dab1e00..a4256e23979 100644
--- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
@@ -2,12 +2,12 @@
.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss ConvDev introduction' }
+ 'aria-label' => _('Dismiss ConvDev introduction') }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
.user-callout-copy
%h4
- Introducing Your Conversational Development Index
+ = _('Introducing Your Conversational Development Index')
%p
- Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.')
.svg-container.convdev
= custom_icon('convdev_overview')
diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/conversational_development_index/_card.html.haml
index 57eda06630b..76af55dcf7a 100644
--- a/app/views/instance_statistics/conversational_development_index/_card.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_card.html.haml
@@ -9,11 +9,11 @@
.board-card-score
.board-card-score-value
= format_score(card.instance_score)
- .board-card-score-name You
+ .board-card-score-name= _('You')
.board-card-score
.board-card-score-value
= format_score(card.leader_score)
- .board-card-score-name Lead
+ .board-card-score-name= _('Lead')
.board-card-score-big
= number_to_percentage(card.percentage_score, precision: 1)
.board-card-buttons
diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
index dd795aee135..4e8f34cd574 100644
--- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
@@ -1,7 +1,7 @@
.container.convdev-empty
.col-sm-12.justify-content-center.text-center
= custom_icon('convdev_no_data')
- %h4 Data is still calculating...
+ %h4= _('Data is still calculating...')
%p
- In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
- = link_to 'Learn more', help_page_path('user/instance_statistics/convdev'), target: '_blank'
+ = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.')
+ = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank'
diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/conversational_development_index/index.html.haml
index 1e7db4982d6..23f90b876a0 100644
--- a/app/views/instance_statistics/conversational_development_index/index.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/index.html.haml
@@ -17,9 +17,9 @@
%h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
= number_to_percentage(@metric.average_percentage_score, precision: 1)
.convdev-header-subtitle
- index
+ = _('index')
%br
- score
+ = _('score')
= link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev')
.convdev-cards.board-card-container
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 5f15ba87729..2fdd65f639b 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -207,7 +207,7 @@
= _('Settings')
%li.divider.fly-out-top-item
= nav_link(path: 'application_settings#show') do
- = link_to admin_application_settings_path, title: _('General') do
+ = link_to admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
= nav_link(path: 'application_settings#integrations') do
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 2629b374e7c..753316b27e2 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -2,7 +2,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
-= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@user)
.row
@@ -77,10 +77,10 @@
.col-lg-8
.row
- if @user.read_only_attribute?(:name)
- = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
+ = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' },
help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
- else
- = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
+ = f.text_field :name, label: 'Full name', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
= f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- if @user.read_only_attribute?(:email)
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 7694217eb28..0be41b5888c 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -21,9 +21,14 @@
- if @project.tag_list.present?
%span.home-panel-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil }
= sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
- = @project.topics_to_show
+
+ - @project.topics_to_show.each do |topic|
+ %a{ class: 'badge badge-pill badge-secondary append-right-5 str-truncated-30', href: explore_projects_path(tag: topic) }
+ = topic.titleize
+
- if @project.has_extra_topics?
- = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
+ .text-nowrap
+ = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
.project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 45e1d32980c..de4653dad2c 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -2,7 +2,7 @@
%div{ class: container_class }
.prepend-top-default.append-bottom-default
.wiki
- = render_wiki_content(@wiki_home, legacy_render_context(params))
+ = render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)
.landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] }
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 3f2d96b70e5..4520cca8cf5 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -21,7 +21,7 @@
Write
%li
- = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do
+ = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do
= editing_preview_title(@blob.name)
= form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index ff460a3831c..66687f087ff 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -2,7 +2,7 @@
.diff-content
- if markup?(@blob.name)
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = markup(@blob.name, @content, legacy_render_context(params))
+ = markup(@blob.name, @content)
- else
.file-content.code.js-syntax-highlight
- unless @diff_lines.empty?
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index 6edbfd91b21..1a77eb078be 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -1,6 +1,4 @@
- blob = viewer.blob
-- context = legacy_render_context(params)
-- unless context[:markdown_engine] == :redcarpet
- - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
+- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {}
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml
index 1e4e9450ffa..1be1087b36f 100644
--- a/app/views/projects/issues/_form.html.haml
+++ b/app/views/projects/issues/_form.html.haml
@@ -1,4 +1,3 @@
= form_for [@project.namespace.becomes(Namespace), @project, @issue],
- html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' },
- data: { markdown_version: @issue.cached_markdown_version } do |f|
+ html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml
index 13b967beba1..a7c9e54506d 100644
--- a/app/views/projects/merge_requests/_form.html.haml
+++ b/app/views/projects/merge_requests/_form.html.haml
@@ -1,4 +1,3 @@
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request],
- html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' },
- data: { markdown_version: @merge_request.cached_markdown_version } do |f|
+ html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index d6f340d0ee2..5111c9fab8d 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -7,7 +7,7 @@
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
-.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
+.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
@@ -59,6 +59,7 @@
#js-vue-discussion-counter
.tab-content#diff-notes-app
+ #js-diff-file-finder
#notes.notes.tab-pane.voting_notes
.row
%section.col-md-12
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 19f5bba75c4..5cc6b5a173b 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,6 +1,5 @@
= form_for [@project.namespace.becomes(Namespace), @project, @milestone],
- html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' },
- data: { markdown_version: @milestone.cached_markdown_version } do |f|
+ html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
.row
.col-md-6
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index 52c6c7ec424..e4efeed04f0 100644
--- a/app/views/projects/tags/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -12,8 +12,7 @@
= form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name),
- html: { class: 'common-note-form release-form js-quick-submit' },
- data: { markdown_version: @release.cached_markdown_version }) do |f|
+ html: { class: 'common-note-form release-form js-quick-submit' }) do |f|
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…"
= render 'shared/notes/hints'
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index d1556dbd077..5bb69563b51 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -1,13 +1,9 @@
- commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}")
- commit_message = commit_message % { page_title: @page.title }
-- if params[:legacy_render] || !commonmark_for_repositories_enabled?
- - markdown_version = CacheMarkdownField::CACHE_REDCARPET_VERSION
-- else
- - markdown_version = 0
= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post,
html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' },
- data: { markdown_version: markdown_version, uploads_path: uploads_path } do |f|
+ data: { uploads_path: uploads_path } do |f|
= form_errors(@page)
- if @page.persisted?
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index 02c5a6ea55c..28353927135 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -12,7 +12,7 @@
.blocks-container
.block.block-first
- if @sidebar_page
- = render_wiki_content(@sidebar_page, legacy_render_context(params))
+ = render_wiki_content(@sidebar_page)
- else
%ul.wiki-pages
= render @sidebar_wiki_entries, context: 'sidebar'
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index 8b348bb4e4f..8e1c054b50c 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -27,6 +27,6 @@
.prepend-top-default.append-bottom-default
.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = render_wiki_content(@page, legacy_render_context(params))
+ = render_wiki_content(@page)
= render 'sidebar'
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index e0130f9a4b5..a60a4501557 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -21,7 +21,7 @@
.file-content.wiki
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
- = markup(snippet.file_name, chunk[:data], legacy_render_context(params))
+ = markup(snippet.file_name, chunk[:data])
- else
.file-content.code
.nothing-here-block= _("Empty file")
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
new file mode 100644
index 00000000000..6da40e1b059
--- /dev/null
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -0,0 +1,19 @@
+- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
+- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
+
+.nothing-here-block
+ .svg-content
+ = image_tag illustration_path, size: '75'
+ .text-content
+ - if user_profile? and current_user.present? and current_user.username == params[:username]
+ %h5= current_user_empty_message_header
+
+ - if current_user_empty_message_description.present?
+ %p= current_user_empty_message_description
+
+ - if secondary_button_link.present?
+ = link_to secondary_button_label, secondary_button_link, class: 'btn btn-create btn-inverted'
+
+ = link_to primary_button_label, primary_button_link, class: 'btn btn-success'
+ - else
+ %h5= visitor_empty_message
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index f50a6bd4d6a..c5b39c7db08 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -1,3 +1,10 @@
+- illustration_path = 'illustrations/profile-page/groups.svg'
+- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.')
+- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- visitor_empty_message = s_('GroupsEmptyState|No groups found')
+
- if groups.any?
- user = local_assigns[:user]
@@ -5,4 +12,9 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group, user: user
- else
- .nothing-here-block= s_("GroupsEmptyState|No groups found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index cb5c64fb91d..41d6ae79c81 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -54,7 +54,7 @@
.note-text.md
= markdown_field(note, :note)
= edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
- .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } }
+ .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
#{note.note}
- if note_editable
= render 'shared/notes/edit', note: note
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 7d90d9ca4a5..13847cd9be1 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -14,6 +14,17 @@
- skip_pagination = false unless local_assigns[:skip_pagination] == true
- compact_mode = false unless local_assigns[:compact_mode] == true
- css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}"
+- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
+- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
+- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
+- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
+- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
+- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
+- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
+- primary_button_label = _('New project')
+- primary_button_link = new_project_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
.js-projects-list-holder
- if any_projects?(projects)
@@ -33,9 +44,18 @@
%span &nbsp;you have no access to.
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
- .nothing-here-block
- .svg-content.svg-130
- = image_tag 'illustrations/profile-page/personal-project.svg'
- %div
- %span
- = s_('UserProfile|This user doesn\'t have any personal projects')
+ - if @contributed_projects
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
+ current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: contributed_projects_visitor_empty_message }
+ - else
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
+ current_user_empty_message_header: own_projects_current_user_empty_message_header,
+ current_user_empty_message_description: own_projects_current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: own_projects_visitor_empty_message }
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index cf9c3055499..3007da0c189 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -3,8 +3,7 @@
.snippet-form-holder
= form_for @snippet, url: url,
- html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
- data: { markdown_version: @snippet.cached_markdown_version } do |f|
+ html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group.row
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 69d41f8fe5e..dab247da251 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,10 +1,21 @@
- link_project = local_assigns.fetch(:link_project, false)
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.')
+- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.')
+- primary_button_label = _('New snippet')
+- primary_button_link = new_snippet_path
+- visitor_empty_message = s_('UserProfile|No snippets found.')
.snippets-list-holder
%ul.content-list
= render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
- if @snippets.empty?
%li
- .nothing-here-block= _("Nothing here.")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
= paginate @snippets, theme: 'gitlab'
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index c8ccaf0c487..421fbf04e28 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -23,7 +23,7 @@ module MailScheduler
end
def self.perform_async(*args)
- super(*ActiveJob::Arguments.serialize(args))
+ super(*Arguments.serialize(args))
end
private
@@ -38,5 +38,34 @@ module MailScheduler
end
end
end
+
+ # Permit ActionController::Parameters for serializable Hash
+ #
+ # Port of
+ # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663
+ module Arguments
+ include ActiveJob::Arguments
+ extend self
+
+ private
+
+ def serialize_argument(argument)
+ case argument
+ when -> (arg) { arg.respond_to?(:permitted?) }
+ serialize_hash(argument.to_h).tap do |result|
+ result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true)
+ end
+ else
+ super
+ end
+ end
+ end
+
+ # Make sure we remove this patch starting with Rails 6.0.
+ if Rails.version.start_with?('6.0')
+ raise <<~MSG
+ Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again.
+ MSG
+ end
end
end
diff --git a/changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml b/changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml
new file mode 100644
index 00000000000..b1177e1717e
--- /dev/null
+++ b/changelogs/unreleased/19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously.yml
@@ -0,0 +1,5 @@
+---
+title: Increase reliability and performance of toggling task items
+merge_request: 23938
+author:
+type: fixed
diff --git a/changelogs/unreleased/28500-empty-states-for-profile-page.yml b/changelogs/unreleased/28500-empty-states-for-profile-page.yml
new file mode 100644
index 00000000000..53f840521ae
--- /dev/null
+++ b/changelogs/unreleased/28500-empty-states-for-profile-page.yml
@@ -0,0 +1,5 @@
+---
+title: Refresh empty states for profile page tabs
+merge_request: 24549
+author:
+type: changed
diff --git a/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml
new file mode 100644
index 00000000000..4ce6787570a
--- /dev/null
+++ b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml
@@ -0,0 +1,5 @@
+---
+title: Display timestamps to messages printed by gitlab:backup:restore rake tasks
+merge_request:
+author: Will Chandler
+type: changed
diff --git a/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml b/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml
new file mode 100644
index 00000000000..04caf8262c6
--- /dev/null
+++ b/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml
@@ -0,0 +1,5 @@
+---
+title: Block emojis and symbol characters from users full names
+merge_request: 24523
+author:
+type: other
diff --git a/changelogs/unreleased/51759-filter-by-language.yml b/changelogs/unreleased/51759-filter-by-language.yml
new file mode 100644
index 00000000000..6b5bedd6b2d
--- /dev/null
+++ b/changelogs/unreleased/51759-filter-by-language.yml
@@ -0,0 +1,5 @@
+---
+title: Add `with_programming_language` filter for projects to API
+merge_request: 24377
+author: Dylan MacKenzie
+type: added
diff --git a/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml b/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml
new file mode 100644
index 00000000000..9d72efdd52a
--- /dev/null
+++ b/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml
@@ -0,0 +1,5 @@
+---
+title: 'API: Support username with dots'
+merge_request: 24395
+author: Robert Schilling
+type: fixed
diff --git a/changelogs/unreleased/52568-external-mr-diffs.yml b/changelogs/unreleased/52568-external-mr-diffs.yml
new file mode 100644
index 00000000000..b1c9d5cb809
--- /dev/null
+++ b/changelogs/unreleased/52568-external-mr-diffs.yml
@@ -0,0 +1,5 @@
+---
+title: Allow merge request diffs to be placed into an object store
+merge_request: 24276
+author:
+type: added
diff --git a/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml b/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml
new file mode 100644
index 00000000000..de12c66e9ef
--- /dev/null
+++ b/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml
@@ -0,0 +1,5 @@
+---
+title: Update project topics styling to use badges design
+merge_request: 24415
+author:
+type: changed
diff --git a/changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml b/changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml
new file mode 100644
index 00000000000..f22524ef4b2
--- /dev/null
+++ b/changelogs/unreleased/55098-ui-bug-adding-group-members-with-lower-permissions.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve UI bug adding group members with lower permissions
+merge_request: 24820
+author:
+type: fixed
diff --git a/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml
new file mode 100644
index 00000000000..19ff408ddf4
--- /dev/null
+++ b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml
@@ -0,0 +1,5 @@
+---
+title: Fix cluster installation processing spinner
+merge_request: 24814
+author:
+type: fixed
diff --git a/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml b/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml
deleted file mode 100644
index b19b4d650fd..00000000000
--- a/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix form functionality for edit tag page
-merge_request: 24645
-author:
-type: fixed
diff --git a/changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml b/changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml
new file mode 100644
index 00000000000..b05ab07e14c
--- /dev/null
+++ b/changelogs/unreleased/57063-implement-new-arguments-iid-for-issuesresolver-in-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Add argument iids for issues in GraphQL
+merge_request: 24802
+author:
+type: added
diff --git a/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml b/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml
new file mode 100644
index 00000000000..3a663ce2132
--- /dev/null
+++ b/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml
@@ -0,0 +1,5 @@
+---
+title: Fix potential Addressable::URI::InvalidURIError
+merge_request: 24908
+author:
+type: fixed
diff --git a/changelogs/unreleased/chore-update-js-regex.yml b/changelogs/unreleased/chore-update-js-regex.yml
new file mode 100644
index 00000000000..d45d0b47457
--- /dev/null
+++ b/changelogs/unreleased/chore-update-js-regex.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade js-regex gem to version 3.1
+merge_request: 24433
+author: rroger
+type: changed
diff --git a/changelogs/unreleased/diff-file-finder.yml b/changelogs/unreleased/diff-file-finder.yml
new file mode 100644
index 00000000000..3160e9fc91b
--- /dev/null
+++ b/changelogs/unreleased/diff-file-finder.yml
@@ -0,0 +1,5 @@
+---
+title: Added fuzzy file finder to merge requests
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/fix_jira_integration_VCS1019.yml b/changelogs/unreleased/fix_jira_integration_VCS1019.yml
new file mode 100644
index 00000000000..3582ec1fe0f
--- /dev/null
+++ b/changelogs/unreleased/fix_jira_integration_VCS1019.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Jira Service password validation on project integration services.
+merge_request: 24896
+author: Daniel Juarez
+type: fixed
diff --git a/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml b/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml
new file mode 100644
index 00000000000..8f6fbdceb54
--- /dev/null
+++ b/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings from `/app/views/email_rejection_mailer`
+merge_request: 24869
+author: George Tsiolis
+type: other
diff --git a/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml b/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml
new file mode 100644
index 00000000000..a3bf54a1339
--- /dev/null
+++ b/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings from `/app/views/instance_statistics`
+merge_request: 24809
+author: George Tsiolis
+type: other
diff --git a/changelogs/unreleased/introduce-environment-search-endpoint.yml b/changelogs/unreleased/introduce-environment-search-endpoint.yml
new file mode 100644
index 00000000000..01851ba7d27
--- /dev/null
+++ b/changelogs/unreleased/introduce-environment-search-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Introduce Internal API for searching environment names
+merge_request: 24923
+author:
+type: added
diff --git a/changelogs/unreleased/issue_55744.yml b/changelogs/unreleased/issue_55744.yml
new file mode 100644
index 00000000000..6a643732b18
--- /dev/null
+++ b/changelogs/unreleased/issue_55744.yml
@@ -0,0 +1,5 @@
+---
+title: Fix template labels not being created on new projects
+merge_request: 24803
+author:
+type: fixed
diff --git a/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml b/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml
new file mode 100644
index 00000000000..18cced2906a
--- /dev/null
+++ b/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml
@@ -0,0 +1,5 @@
+---
+title: Display SAML failure messages instead of expecting CSRF token
+merge_request: 24509
+author:
+type: fixed
diff --git a/changelogs/unreleased/jprovazn-remove-redcarpet.yml b/changelogs/unreleased/jprovazn-remove-redcarpet.yml
new file mode 100644
index 00000000000..4e12de2d19b
--- /dev/null
+++ b/changelogs/unreleased/jprovazn-remove-redcarpet.yml
@@ -0,0 +1,5 @@
+---
+title: Removed deprecated Redcarpet markdown engine.
+merge_request:
+author:
+type: removed
diff --git a/changelogs/unreleased/knative-list.yml b/changelogs/unreleased/knative-list.yml
new file mode 100644
index 00000000000..754d8e172cf
--- /dev/null
+++ b/changelogs/unreleased/knative-list.yml
@@ -0,0 +1,5 @@
+---
+title: Modified Knative list view to provide more details
+merge_request: 24072
+author: Chris Baumbauer
+type: changed
diff --git a/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml
new file mode 100644
index 00000000000..732e4baf4e9
--- /dev/null
+++ b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml
@@ -0,0 +1,5 @@
+---
+title: Don't create new merge request pipeline without commits
+merge_request: 24503
+author: Hiroyuki Sato
+type: added
diff --git a/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml b/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml
deleted file mode 100644
index 3ba62b92413..00000000000
--- a/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Adjusts suggestions unable to be applied
-merge_request: 24603
-author:
-type: fixed
diff --git a/changelogs/unreleased/pl-serialize-ac-parameters.yml b/changelogs/unreleased/pl-serialize-ac-parameters.yml
new file mode 100644
index 00000000000..aad222b5506
--- /dev/null
+++ b/changelogs/unreleased/pl-serialize-ac-parameters.yml
@@ -0,0 +1,5 @@
+---
+title: Make `ActionController::Parameters` serializable for sidekiq jobs
+merge_request: 24864
+author:
+type: fixed
diff --git a/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml b/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml
new file mode 100644
index 00000000000..abce9dcc0c6
--- /dev/null
+++ b/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml
@@ -0,0 +1,5 @@
+---
+title: Update last_activity_on for Users on some main GET endpoints
+merge_request: 24642
+author:
+type: changed
diff --git a/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml b/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml
new file mode 100644
index 00000000000..a216d294b30
--- /dev/null
+++ b/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml
@@ -0,0 +1,5 @@
+---
+title: Extracted ReplyPlaceholder to its own component
+merge_request: 24507
+author: Martin Hobert
+type: other
diff --git a/changelogs/unreleased/search-title.yml b/changelogs/unreleased/search-title.yml
new file mode 100644
index 00000000000..ff0933ed0b2
--- /dev/null
+++ b/changelogs/unreleased/search-title.yml
@@ -0,0 +1,5 @@
+---
+title: Add 'in' filter that modifies scope of 'search' filter to issues and merge requests API
+merge_request: 24350
+author: Hiroyuki Sato
+type: added
diff --git a/changelogs/unreleased/sh-encode-content-disposition.yml b/changelogs/unreleased/sh-encode-content-disposition.yml
new file mode 100644
index 00000000000..b40ee6a85a8
--- /dev/null
+++ b/changelogs/unreleased/sh-encode-content-disposition.yml
@@ -0,0 +1,5 @@
+---
+title: Encode Content-Disposition filenames
+merge_request: 24919
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-fix-issue-9357.yml b/changelogs/unreleased/sh-fix-issue-9357.yml
deleted file mode 100644
index 756cd6047b8..00000000000
--- a/changelogs/unreleased/sh-fix-issue-9357.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix 500 errors with legacy appearance logos
-merge_request: 24615
-author:
-type: fixed
diff --git a/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml b/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml
deleted file mode 100644
index 8c0b000220f..00000000000
--- a/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix import handling errors in Bitbucket Server importer
-merge_request: 24499
-author:
-type: fixed
diff --git a/changelogs/unreleased/update-gitaly.yml b/changelogs/unreleased/update-gitaly.yml
new file mode 100644
index 00000000000..4ba42a689a7
--- /dev/null
+++ b/changelogs/unreleased/update-gitaly.yml
@@ -0,0 +1,5 @@
+---
+title: Update Gitaly to v1.17.0
+merge_request: 24873
+author:
+type: other
diff --git a/changelogs/unreleased/update-pages-config-only-when-changed.yml b/changelogs/unreleased/update-pages-config-only-when-changed.yml
new file mode 100644
index 00000000000..8d9e02df678
--- /dev/null
+++ b/changelogs/unreleased/update-pages-config-only-when-changed.yml
@@ -0,0 +1,5 @@
+---
+title: Do not reload daemon if configuration file of pages does not change
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/update-pages-extensionless-urls.yml b/changelogs/unreleased/update-pages-extensionless-urls.yml
new file mode 100644
index 00000000000..13b3e1df500
--- /dev/null
+++ b/changelogs/unreleased/update-pages-extensionless-urls.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for extensionless pages URLs
+merge_request: 24876
+author:
+type: added
diff --git a/changelogs/unreleased/update-ui-admin-appearance.yml b/changelogs/unreleased/update-ui-admin-appearance.yml
new file mode 100644
index 00000000000..7bc35029d77
--- /dev/null
+++ b/changelogs/unreleased/update-ui-admin-appearance.yml
@@ -0,0 +1,5 @@
+---
+title: Update UI for admin appearance settings
+merge_request: 24685
+author:
+type: other
diff --git a/changelogs/unreleased/update-workhorse-8-2-0.yml b/changelogs/unreleased/update-workhorse-8-2-0.yml
new file mode 100644
index 00000000000..7d593917a25
--- /dev/null
+++ b/changelogs/unreleased/update-workhorse-8-2-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update Workhorse to v8.2.0
+merge_request: 24909
+author:
+type: fixed
diff --git a/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml b/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml
new file mode 100644
index 00000000000..1ec276b4abc
--- /dev/null
+++ b/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Use deployment relation to get an environment name
+merge_request: 24890
+author:
+type: performance
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 6fc33e8971e..be23166cb7b 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -166,6 +166,23 @@ production: &base
# aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
# endpoint: 'https://s3.amazonaws.com' # default: nil - Useful for S3 compliant services such as DigitalOcean Spaces
+ ## Merge request external diff storage
+ external_diffs:
+ # If disabled (the default), the diffs are in-database. Otherwise, they can
+ # be stored on disk, or in object storage
+ enabled: false
+ # The location where external diffs are stored (default: shared/lfs-external-diffs).
+ # storage_path: shared/external-diffs
+ # object_store:
+ # enabled: false
+ # remote_directory: external-diffs
+ # background_upload: false
+ # proxy_download: false
+ # connection:
+ # provider: AWS
+ # aws_access_key_id: AWS_ACCESS_KEY_ID
+ # aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ # region: us-east-1
## Git LFS
lfs:
@@ -733,6 +750,18 @@ test:
<<: *base
gravatar:
enabled: true
+ external_diffs:
+ enabled: false
+ # The location where external diffs are stored (default: shared/external-diffs).
+ # storage_path: shared/external-diffs
+ object_store:
+ enabled: false
+ remote_directory: external-diffs # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
lfs:
enabled: false
# The location where LFS objects are stored (default: shared/lfs-objects).
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 1aed41e02ab..dfcf1e648b4 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -216,6 +216,14 @@ Settings.pages['admin'] ||= Settingslogic.new({})
Settings.pages.admin['certificate'] ||= ''
#
+# External merge request diffs
+#
+Settings['external_diffs'] ||= Settingslogic.new({})
+Settings.external_diffs['enabled'] = false if Settings.external_diffs['enabled'].nil?
+Settings.external_diffs['storage_path'] = Settings.absolute(Settings.external_diffs['storage_path'] || File.join(Settings.shared['path'], 'external-diffs'))
+Settings.external_diffs['object_store'] = ObjectStoreSettings.parse(Settings.external_diffs['object_store'])
+
+#
# Git LFS
#
Settings['lfs'] ||= Settingslogic.new({})
diff --git a/config/initializers/sprockets_base_file_digest_key.rb b/config/initializers/sprockets_base_file_digest_key.rb
new file mode 100644
index 00000000000..81ff3812091
--- /dev/null
+++ b/config/initializers/sprockets_base_file_digest_key.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+Sprockets::Base.prepend(Gitlab::Patch::SprocketsBaseFileDigestKey)
diff --git a/config/routes/import.rb b/config/routes/import.rb
index 69df82611f2..da5c31d0062 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -1,7 +1,7 @@
# Alias import callbacks under the /users/auth endpoint so that
# the OAuth2 callback URL can be restricted under http://example.com/users/auth
# instead of http://example.com.
-Devise.omniauth_providers.each do |provider|
+Devise.omniauth_providers.map(&:downcase).each do |provider|
next if provider == 'ldapmain'
get "/users/auth/-/import/#{provider}/callback", to: "import/#{provider}#callback", as: "users_import_#{provider}_callback"
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 21793e7756a..d730479cf2b 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -224,6 +224,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
collection do
get :metrics, action: :metrics_redirect
get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ }
+ get :search
end
resources :deployments, only: [:index] do
diff --git a/db/migrate/20190109153125_add_merge_request_external_diffs.rb b/db/migrate/20190109153125_add_merge_request_external_diffs.rb
new file mode 100644
index 00000000000..c67903c7f67
--- /dev/null
+++ b/db/migrate/20190109153125_add_merge_request_external_diffs.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddMergeRequestExternalDiffs < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ # Allow the merge request diff to store details about an external file
+ add_column :merge_request_diffs, :external_diff, :string
+ add_column :merge_request_diffs, :external_diff_store, :integer
+ add_column :merge_request_diffs, :stored_externally, :boolean
+
+ # The diff for each file is mapped to a range in the external file
+ add_column :merge_request_diff_files, :external_diff_offset, :integer
+ add_column :merge_request_diff_files, :external_diff_size, :integer
+
+ # If the diff is in object storage, it will be null in the database
+ change_column_null :merge_request_diff_files, :diff, true
+ end
+end
diff --git a/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb b/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb
new file mode 100644
index 00000000000..190b6f958fd
--- /dev/null
+++ b/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AddColumnsProjectErrorTrackingSettings < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :project_error_tracking_settings, :project_name, :string
+ add_column :project_error_tracking_settings, :organization_name, :string
+
+ change_column_default :project_error_tracking_settings, :enabled, from: true, to: false
+
+ change_column_null :project_error_tracking_settings, :api_url, true
+ end
+end
diff --git a/db/post_migrate/20190131122559_fix_null_type_labels.rb b/db/post_migrate/20190131122559_fix_null_type_labels.rb
new file mode 100644
index 00000000000..83bb613990c
--- /dev/null
+++ b/db/post_migrate/20190131122559_fix_null_type_labels.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class FixNullTypeLabels < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ update_column_in_batches(:labels, :type, 'ProjectLabel') do |table, query|
+ query.where(
+ table[:project_id].not_eq(nil)
+ .and(table[:template].eq(false))
+ .and(table[:type].eq(nil))
+ )
+ end
+ end
+
+ def down
+ # no action
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7c1733becb9..20c8dab4c3e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20190124200344) do
+ActiveRecord::Schema.define(version: 20190131122559) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -1203,8 +1203,10 @@ ActiveRecord::Schema.define(version: 20190124200344) do
t.string "b_mode", null: false
t.text "new_path", null: false
t.text "old_path", null: false
- t.text "diff", null: false
+ t.text "diff"
t.boolean "binary"
+ t.integer "external_diff_offset"
+ t.integer "external_diff_size"
t.index ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_files_on_mr_diff_id_and_order", unique: true, using: :btree
end
@@ -1218,6 +1220,9 @@ ActiveRecord::Schema.define(version: 20190124200344) do
t.string "head_commit_sha"
t.string "start_commit_sha"
t.integer "commits_count"
+ t.string "external_diff"
+ t.integer "external_diff_store"
+ t.boolean "stored_externally"
t.index ["merge_request_id", "id"], name: "index_merge_request_diffs_on_merge_request_id_and_id", using: :btree
end
@@ -1574,10 +1579,12 @@ ActiveRecord::Schema.define(version: 20190124200344) do
end
create_table "project_error_tracking_settings", primary_key: "project_id", id: :integer, force: :cascade do |t|
- t.boolean "enabled", default: true, null: false
- t.string "api_url", null: false
+ t.boolean "enabled", default: false, null: false
+ t.string "api_url"
t.string "encrypted_token"
t.string "encrypted_token_iv"
+ t.string "project_name"
+ t.string "organization_name"
end
create_table "project_features", force: :cascade do |t|
diff --git a/doc/administration/index.md b/doc/administration/index.md
index 0b673d61139..184754cd467 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -48,6 +48,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [Third party offers](../user/admin_area/settings/third_party_offers.md)
- [Compliance](compliance.md): A collection of features from across the application that you may configure to help ensure that your GitLab instance and DevOps workflow meet compliance standards.
- [Diff limits](../user/admin_area/diff_limits.md): Configure the diff rendering size limits of branch comparison pages.
+- [Merge request diffs](merge_request_diffs.md): Configure the diffs shown on merge requests
- [Broadcast Messages](../user/admin_area/broadcast_messages.md): Send messages to GitLab users through the UI.
#### Customizing GitLab's appearance
diff --git a/doc/administration/merge_request_diffs.md b/doc/administration/merge_request_diffs.md
new file mode 100644
index 00000000000..94620c3d3a0
--- /dev/null
+++ b/doc/administration/merge_request_diffs.md
@@ -0,0 +1,154 @@
+# Merge request diffs administration
+
+> **Notes:**
+> - External merge request diffs introduced in GitLab 11.8
+
+Merge request diffs are size-limited copies of diffs associated with merge
+requests. When viewing a merge request, diffs are sourced from these copies
+wherever possible as a performance optimization.
+
+By default, merge request diffs are stored in the database, in a table named
+`merge_request_diff_files`. Larger installations may find this table grows too
+large, in which case, switching to external storage is recommended.
+
+### Using external storage
+
+Merge request diffs can be stored on disk, or in object storage. In general, it
+is better to store the diffs in the database than on disk.
+
+To enable external storage of merge request diffs:
+
+---
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ ```
+
+1. _The external diffs will be stored in in
+ `/var/opt/gitlab/gitlab-rails/shared/external-diffs`._ To change the path,
+ for example to `/mnt/storage/external-diffs`, edit `/etc/gitlab/gitlab.rb`
+ and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_storage_path'] = "/mnt/storage/external-diffs"
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ ```
+
+1. _The external diffs will be stored in
+ `/home/git/gitlab/shared/external-diffs`._ To change the path, for example
+ to `/mnt/storage/external-diffs`, edit `/home/git/gitlab/config/gitlab.yml`
+ and add or amend the following lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ storage_path: /mnt/storage/external-diffs
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
+### Using object storage
+
+Instead of storing the external diffs on disk, we recommended you use an object
+store like AWS S3 instead. This configuration relies on valid AWS credentials to
+be configured already.
+
+### Object Storage Settings
+
+For source installations, these settings are nested under `external_diffs:` and
+then `object_store:`. On omnibus installs, they are prefixed by
+`external_diffs_object_store_`.
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable/disable object storage | `false` |
+| `remote_directory` | The bucket name where external diffs will be stored| |
+| `direct_upload` | Set to true to enable direct upload of external diffs without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` |
+| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` |
+| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` |
+| `connection` | Various connection options described below | |
+
+#### S3 compatible connection settings
+
+The connection settings match those provided by [Fog](https://github.com/fog), and are as follows:
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `provider` | Always `AWS` for compatible hosts | AWS |
+| `aws_access_key_id` | AWS credentials, or compatible | |
+| `aws_secret_access_key` | AWS credentials, or compatible | |
+| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 |
+| `region` | AWS region | us-east-1 |
+| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com |
+| `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) |
+| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false |
+| `use_iam_profile` | Set to true to use IAM profile instead of access keys | false
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with
+ the values you want:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ gitlab_rails['external_diffs_object_store_enabled'] = true
+ gitlab_rails['external_diffs_object_store_remote_directory'] = "external-diffs"
+ gitlab_rails['external_diffs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'aws_access_key_id' => 'AWS_ACCESS_KEY_ID',
+ 'aws_secret_access_key' => 'AWS_SECRET_ACCESS_KEY'
+ }
+ ```
+
+ NOTE: if you are using AWS IAM profiles, be sure to omit the
+ AWS access key and secret access key/value pairs. For example:
+
+ ```ruby
+ gitlab_rails['external_diffs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'use_iam_profile' => true
+ }
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ object_store:
+ enabled: true
+ remote_directory: "external-diffs" # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: eu-central-1
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
diff --git a/doc/api/issues.md b/doc/api/issues.md
index 6d8683601f6..ed3165d95df 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -31,6 +31,7 @@ GET /issues?iids[]=42&iids[]=43
GET /issues?author_id=5
GET /issues?assignee_id=5
GET /issues?my_reaction_emoji=star
+GET /issues?search=foo&in=title
```
| Attribute | Type | Required | Description |
@@ -46,6 +47,7 @@ GET /issues?my_reaction_emoji=star
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search issues against their `title` and `description` |
+| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` |
| `created_after` | datetime | no | Return issues created on or after the given time |
| `created_before` | datetime | no | Return issues created on or before the given time |
| `updated_after` | datetime | no | Return issues updated on or after the given time |
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index c9b271eada3..802ff1d1df9 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -24,6 +24,7 @@ GET /merge_requests?labels=bug,reproduced
GET /merge_requests?author_id=5
GET /merge_requests?my_reaction_emoji=star
GET /merge_requests?scope=assigned_to_me
+GET /merge_requests?search=foo&in=title
```
Parameters:
@@ -47,6 +48,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` |
+| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` |
| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
```json
@@ -182,6 +184,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` |
+| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
```json
[
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 1296b435792..3c0c956ddc2 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -54,6 +54,7 @@ GET /projects
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
+| `with_programming_language` | string | no | Limit by projects which use the given programming language |
| `wiki_checksum_failed` | boolean | no | Limit projects where the wiki checksum calculation has failed _([Introduced][ee-6137] in [GitLab Premium][eep] 11.2)_ |
| `repository_checksum_failed` | boolean | no | Limit projects where the repository checksum calculation has failed _([Introduced][ee-6137] in [GitLab Premium][eep] 11.2)_ |
| `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) |
@@ -279,6 +280,7 @@ GET /users/:user_id/projects
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
+| `with_programming_language` | string | no | Limit by projects which use the given programming language |
| `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) |
```json
diff --git a/doc/api/users.md b/doc/api/users.md
index 6000b9b900f..fd8778abb17 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -1212,6 +1212,7 @@ The activities that update the timestamp are:
- Git HTTP/SSH activities (such as clone, push)
- User logging in into GitLab
+ - User visiting pages related to Dashboards, Projects, Issues and Merge Requests ([introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/54947) in GitLab 11.8)
By default, it shows the activity for all users in the last 6 months, but this can be
amended by using the `from` parameter.
diff --git a/doc/ci/examples/browser_performance.md b/doc/ci/examples/browser_performance.md
index 7c3b3a65675..b47038011de 100644
--- a/doc/ci/examples/browser_performance.md
+++ b/doc/ci/examples/browser_performance.md
@@ -41,7 +41,7 @@ The above example will create a `performance` job in your CI/CD pipeline and wil
Sitespeed.io against the webpage you defined in `URL` to gather key metrics.
The [GitLab plugin](https://gitlab.com/gitlab-org/gl-performance) for
Sitespeed.io is downloaded in order to save the report as a
-[Performance report artifact](https://docs.gitlab.com/ee//ci/yaml/README.html#artifactsreportsperformance)
+[Performance report artifact](../yaml/README.md#artifactsreportsperformance-premium)
that you can later download and analyze.
Due to implementation limitations we always take the latest Performance artifact available.
diff --git a/doc/ci/examples/code_quality.md b/doc/ci/examples/code_quality.md
index ae000b9d30d..3e7d6e7e3f7 100644
--- a/doc/ci/examples/code_quality.md
+++ b/doc/ci/examples/code_quality.md
@@ -36,7 +36,7 @@ code_quality:
The above example will create a `code_quality` job in your CI/CD pipeline which
will scan your source code for code quality issues. The report will be saved as a
-[Code Quality report artifact](../../ci/yaml/README.md#artifactsreportscodequality)
+[Code Quality report artifact](../yaml/README.md#artifactsreportscodequality-starter)
that you can later download and analyze.
Due to implementation limitations we always take the latest Code Quality artifact available.
diff --git a/doc/ci/examples/container_scanning.md b/doc/ci/examples/container_scanning.md
index 31c3df81fef..e8e9c73d1b2 100644
--- a/doc/ci/examples/container_scanning.md
+++ b/doc/ci/examples/container_scanning.md
@@ -51,7 +51,7 @@ The above example will create a `container_scanning` job in your CI/CD pipeline,
the image from the [Container Registry](../../user/project/container_registry.md)
(whose name is defined from the two `CI_APPLICATION_` variables) and scan it
for possible vulnerabilities. The report will be saved as a
-[Container Scanning report artifact](https://docs.gitlab.com/ee//ci/yaml/README.html#artifactsreportscontainer_scanning)
+[Container Scanning report artifact](../yaml/README.md#artifactsreportscontainer_scanning-ultimate)
that you can later download and analyze.
Due to implementation limitations we always take the latest Container Scanning artifact available.
diff --git a/doc/ci/examples/dast.md b/doc/ci/examples/dast.md
index 0ca89eb6700..ab0ca13d2cf 100644
--- a/doc/ci/examples/dast.md
+++ b/doc/ci/examples/dast.md
@@ -40,7 +40,7 @@ dast:
The above example will create a `dast` job in your CI/CD pipeline which will run
the tests on the URL defined in the `website` variable (change it to use your
own) and scan it for possible vulnerabilities. The report will be saved as a
-[DAST report artifact](https://docs.gitlab.com/ee//ci/yaml/README.html#artifactsreportsdast)
+[DAST report artifact](../yaml/README.md#artifactsreportsdast-ultimate)
that you can later download and analyze.
Due to implementation limitations we always take the latest DAST artifact available.
diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md
index d2a00b9218d..c41f3b7e82d 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -3,7 +3,7 @@
> Introduced in GitLab 8.8.
NOTE: **Note:**
-If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository-starter),
you may need to enable pipeline triggering in your project's
**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md
index 1ec8a8c89c9..9684cb6ed98 100644
--- a/doc/ci/quick_start/README.md
+++ b/doc/ci/quick_start/README.md
@@ -127,7 +127,7 @@ Now if you go to the **Pipelines** page you will see that the pipeline is
pending.
NOTE: **Note:**
-If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository-starter),
you may need to enable pipeline triggering in your project's
**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 4c39b14b1d0..df14376dd36 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -11,7 +11,7 @@ If you want a quick introduction to GitLab CI, follow our
[quick start guide](../quick_start/README.md).
NOTE: **Note:**
-If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository-starter),
you may need to enable pipeline triggering in your project's
**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md
index 828f9bfeec6..436d0a38f31 100644
--- a/doc/development/documentation/index.md
+++ b/doc/development/documentation/index.md
@@ -27,7 +27,7 @@ The source of the documentation is maintained in the following repository locati
| Project | Path |
| --- | --- |
| [GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-ce/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc) |
-| [GitLab Enterprise Edition](https://gitlab.com/gitlab-org/gitlab-ce/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/doc) |
+| [GitLab Enterprise Edition](https://gitlab.com/gitlab-org/gitlab-ee/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/doc) |
| [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-runner/) | [`/docs`](https://gitlab.com/gitlab-org/gitlab-runner/tree/master/docs) |
| [Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab/) | [`/doc`](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/doc) |
diff --git a/doc/development/file_storage.md b/doc/development/file_storage.md
index b90dc90e424..597812c8c49 100644
--- a/doc/development/file_storage.md
+++ b/doc/development/file_storage.md
@@ -18,6 +18,7 @@ There are many places where file uploading is used, according to contexts:
- Issues/MR/Notes Legacy Markdown attachments
- CI Artifacts (archive, metadata, trace)
- LFS Objects
+ - Merge request diffs
## Disk storage
@@ -37,6 +38,7 @@ they are still not 100% standardized. You can see them below:
| Issues/MR/Notes Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
| CI Artifacts (CE) | yes | shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id (:disk_hash is SHA256 digest of project_id) | `JobArtifactUploader` | Ci::JobArtifact |
| LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject |
+| External merge request diffs | yes | shared/external-diffs/merge_request_diffs/mr-:parent_id/diff-:id | `ExternalDiffUploader` | MergeRequestDiff |
CI Artifacts and LFS Objects behave differently in CE and EE. In CE they inherit the `GitlabUploader`
while in EE they inherit the `ObjectStorage` and store files in and S3 API compatible object store.
diff --git a/doc/development/sql.md b/doc/development/sql.md
index 06005a0a6f8..47519d39e74 100644
--- a/doc/development/sql.md
+++ b/doc/development/sql.md
@@ -256,32 +256,12 @@ violation, for example.
Using transactions does not solve this problem.
-The following pattern should be used to avoid the problem:
+To solve this we've added the `ApplicationRecord.safe_find_or_create_by`.
-```ruby
-Project.transaction do
- begin
- User.find_or_create_by(username: "foo")
- rescue ActiveRecord::RecordNotUnique
- retry
- end
-end
-```
-
-If the above block is run inside a transaction and hits the race
-condition, the transaction is aborted and we cannot simply retry (any
-further queries inside the aborted transaction are going to fail). We
-can employ [nested transactions](http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions)
-here to only rollback the "inner transaction". Note that `requires_new: true` is required here.
+This method can be used just as you would the normal
+`find_or_create_by` but it wraps the call in a *new* transaction and
+retries if it were to fail because of an
+`ActiveRecord::RecordNotUnique` error.
-```ruby
-Project.transaction do
- begin
- User.transaction(requires_new: true) do
- User.find_or_create_by(username: "foo")
- end
- rescue ActiveRecord::RecordNotUnique
- retry
- end
-end
-```
+To be able to use this method, make sure the model you want to use
+this on inherits from `ApplicationRecord`.
diff --git a/doc/ssh/README.md b/doc/ssh/README.md
index 09a97fcea07..80de39c207a 100644
--- a/doc/ssh/README.md
+++ b/doc/ssh/README.md
@@ -19,7 +19,7 @@ comes pre-installed on GNU/Linux and macOS, but not on Windows.
Depending on your Windows version, there are different methods to work with
SSH keys.
-### Installing the SSH client for Windows 10
+### Windows 10: Windows Subsystem for Linux
Starting with Windows 10, you can
[install the Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/install-win10)
@@ -27,10 +27,10 @@ where you can run Linux distributions directly on Windows, without the overhead
of a virtual machine. Once installed and set up, you'll have the Git and SSH
clients at your disposal.
-### Installing the SSH client for Windows 8.1 and Windows 7
+### Windows 10, 8.1, and 7: Git for Windows
The easiest way to install Git and the SSH client on Windows 8.1 and Windows 7
-is [Git for Windows](https://gitforwindows.org). It provides a BASH
+is [Git for Windows](https://gitforwindows.org). It provides a Bash
emulation (Git Bash) used for running Git from the command line and the
`ssh-keygen` command that is useful to create SSH keys as you'll learn below.
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
index 68a0f1a5837..c1c9b8bf43c 100644
--- a/doc/user/gitlab_com/index.md
+++ b/doc/user/gitlab_com/index.md
@@ -46,7 +46,7 @@ Below are the settings for [GitLab Pages].
| Setting | GitLab.com | Default |
| ----------------------- | ---------------- | ------------- |
| Domain name | `gitlab.io` | - |
-| IP address | `52.167.214.135` | - |
+| IP address | `35.185.44.232` | - |
| Custom domains support | yes | no |
| TLS certificates support| yes | no |
diff --git a/doc/user/instance_statistics/user_cohorts.md b/doc/user/instance_statistics/user_cohorts.md
index f52f24ef5f7..e76363a6d9f 100644
--- a/doc/user/instance_statistics/user_cohorts.md
+++ b/doc/user/instance_statistics/user_cohorts.md
@@ -25,3 +25,4 @@ How do we measure the activity of users? GitLab considers a user active if:
- The user signs in.
- The user has Git activity (whether push or pull).
+- The user visits pages related to Dashboards, Projects, Issues and Merge Requests ([introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/54947) in GitLab 11.8).
diff --git a/doc/user/markdown.md b/doc/user/markdown.md
index f2448f240ca..9a01625f3ff 100644
--- a/doc/user/markdown.md
+++ b/doc/user/markdown.md
@@ -31,8 +31,10 @@ dependency to do so. Please see the [`github-markup` gem readme](https://github.
> As of 11.1, GitLab uses the [CommonMark Ruby Library][commonmarker] for Markdown
processing of all new issues, merge requests, comments, and other Markdown content
in the GitLab system. As of 11.3, wiki pages and Markdown files (`.md`) in the
-repositories are also processed with CommonMark. Older content in issues/comments
-are still processed using the [Redcarpet Ruby library][redcarpet].
+repositories are also processed with CommonMark. As of 11.8, the [Redcarpet
+Ruby library][redcarpet] has been removed and all issues/comments, including
+those from pre-11.1, are now processed using [CommonMark Ruby
+Library][commonmarker].
>
> The documentation website had its [markdown engine migrated from Redcarpet to Kramdown](https://gitlab.com/gitlab-com/gitlab-docs/merge_requests/108)
in October 2018.
@@ -41,11 +43,11 @@ in October 2018.
### Transitioning to CommonMark
-You may have Markdown documents in your repository that were written using some
-of the nuances of RedCarpet's version of Markdown. Since CommonMark uses a
-slightly stricter syntax, these documents may now display a little strangely
-since we've transitioned to CommonMark. Numbered lists with nested lists in
-particular can be displayed incorrectly.
+You may have older issues/merge requests or Markdown documents in your
+repository that were written using some of the nuances of RedCarpet's version
+of Markdown. Since CommonMark uses a slightly stricter syntax, these documents
+may now display a little strangely since we've transitioned to CommonMark.
+Numbered lists with nested lists in particular can be displayed incorrectly.
It is usually quite easy to fix. In the case of a nested list such as this:
@@ -65,11 +67,6 @@ simply add a space to each nested item:
In the documentation below, we try to highlight some of the differences.
-If you have a need to view a document using RedCarpet, you can add the token
-`legacy_render=1` to the end of the url, like this:
-
-https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md?legacy_render=1
-
If you have a large volume of Markdown files, it can be tedious to determine
if they will be displayed correctly or not. You can use the
[diff_redcarpet_cmark](https://gitlab.com/digitalmoksha/diff_redcarpet_cmark)
@@ -677,7 +674,7 @@ Becomes:
+ Or pluses
If a list item contains multiple paragraphs,
-each subsequent paragraph should be indented to the same level as the start of the list item text (_Redcarpet: paragraph should be indented with four spaces._)
+each subsequent paragraph should be indented to the same level as the start of the list item text
Example:
@@ -841,7 +838,7 @@ These details <em>will</em> remain <strong>hidden</strong> until expanded.
</details>
</p>
-**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example. _Redcarpet does not support Markdown inside these tags. You can work around this by using HTML, for example you can use `<pre><code>` tags instead of [code fences](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting)._
+**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example.
```html
<details>
diff --git a/doc/user/project/operations/index.md b/doc/user/project/operations/index.md
new file mode 100644
index 00000000000..b0f9936be5c
--- /dev/null
+++ b/doc/user/project/operations/index.md
@@ -0,0 +1,11 @@
+# Project operations
+
+GitLab provides a variety of tools to help operate and maintain
+your applications:
+
+- Collect [Prometheus metrics](../integrations/prometheus_library/index.md).
+- Deploy to different [environments](../../../ci/environments.md).
+- Connect your project to a [Kubernetes cluster](../clusters/index.md).
+- Discover and view errors generated by your applications with [Error Tracking](error_tracking.md).
+- Create, toggle, and remove [Feature Flags](https://docs.gitlab.com/ee/user/project/operations/feature_flags.html). **[PREMIUM]**
+- [Trace](https://docs.gitlab.com/ee/user/project/operations/tracing.html) the performance and health of a deployed application. **[ULTIMATE]**
diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md
index cea9628966d..68dd3330d7a 100644
--- a/doc/user/project/pages/getting_started_part_three.md
+++ b/doc/user/project/pages/getting_started_part_three.md
@@ -264,7 +264,7 @@ your Pages project are the same.
1. A PEM certificate
1. An intermediate certificate
-1. A public key
+1. A private key
![Pages project - adding certificates](img/add_certificate_to_pages.png)
@@ -280,7 +280,7 @@ Usually it's combined with the PEM certificate, but there are
some cases in which you need to add them manually.
[CloudFlare certs](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/)
are one of these cases.
-- A public key is an encrypted key which validates
+- A private key is an encrypted key which validates
your PEM against your domain.
### Now what?
@@ -293,7 +293,7 @@ of this, it's simple:
and paste the root certificate (usually available from your CA website)
and paste it in the [same field as your PEM certificate](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/),
just jumping a line between them.
-- Copy your public key and paste it in the last field
+- Copy your private key and paste it in the last field
>**Note:**
**Do not** open certificates or encryption keys in
diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md
index b0560c2f44c..8dbbdd051f1 100644
--- a/doc/user/project/pages/getting_started_part_two.md
+++ b/doc/user/project/pages/getting_started_part_two.md
@@ -31,12 +31,26 @@ The optional settings, custom domain, DNS records, and SSL/TLS certificates, are
## Project
Your GitLab Pages project is a regular project created the
-same way you do for the other ones. To get started with GitLab Pages, you have two ways:
+same way you do for the other ones. To get started with GitLab Pages, you have three ways:
+- Use one of the popular templates already in the app,
- Fork one of the templates from Page Examples, or
- Create a new project from scratch
-Let's go over both options.
+Let's go over each option.
+
+### Use one of the popular Pages templates bundled with GitLab
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/47857)
+in GitLab 11.8.
+
+The simplest way to create a GitLab Pages site is to use one of the most
+popular templates, which come already bundled and ready to go. To use one
+of these templates:
+
+1. From the top navigation, click the **+** button and select **New project**
+1. Select **Create from Template**
+1. Choose one of the templates starting with **Pages**
### Fork a project to get started from
diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md
index ce4fccdaff3..11f6165fcb4 100644
--- a/doc/user/project/pages/index.md
+++ b/doc/user/project/pages/index.md
@@ -91,8 +91,8 @@ site under the HTTPS protocol.
## Getting started
-To get started with GitLab Pages, you can either [create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch)
-or quickly start from copying an existing example project, as follows:
+To get started with GitLab Pages, you can either [create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch),
+use a [bundled template](getting_started_part_two.md#use-one-of-the-popular-pages-templates-bundled-with-gitlab), or copy any of our existing example projects:
1. Choose an [example project](https://gitlab.com/pages) to [fork](../../../gitlab-basics/fork-project.md#how-to-fork-a-project):
by forking a project, you create a copy of the codebase you're forking from to start from a template instead of starting from scratch.
diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md
index 2bb6fcd9d74..cebff38ba88 100644
--- a/doc/user/project/pages/introduction.md
+++ b/doc/user/project/pages/introduction.md
@@ -356,6 +356,57 @@ By pre-compressing the files and including both versions in the artifact, Pages
can serve requests for both compressed and uncompressed content without
needing to compress files on-demand.
+### Resolving ambiguous URLs
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-pages/issues/95) in GitLab 11.8
+
+GitLab Pages makes assumptions about which files to serve when receiving a
+request for a URL that does not include an extension.
+
+Consider a Pages site deployed with the following files:
+
+```
+public/
+├─┬ index.html
+│ ├ data.html
+│ └ info.html
+│
+├── data/
+│ └── index.html
+├── info/
+│ └── details.html
+└── other/
+ └── index.html
+```
+
+Pages supports reaching each of these files through several different URLs. In
+particular, it will always look for an `index.html` file if the URL only
+specifies the directory. If the URL references a file that doesn't exist, but
+adding `.html` to the URL leads to a file that *does* exist, it will be served
+instead. Here are some examples of what will happen given the above Pages site:
+
+| URL path | HTTP response | File served |
+| -------------------- | ------------- | ----------- |
+| `/` | `200 OK` | `public/index.html` |
+| `/index.html` | `200 OK` | `public/index.html` |
+| `/index` | `200 OK` | `public/index.html` |
+| `/data` | `200 OK` | `public/data/index.html` |
+| `/data/` | `200 OK` | `public/data/index.html` |
+| `/data.html` | `200 OK` | `public/data.html` |
+| `/info` | `200 OK` | `public/info.html` |
+| `/info/` | `200 OK` | `public/info.html` |
+| `/info.html` | `200 OK` | `public/info.html` |
+| `/info/details` | `200 OK` | `public/info/details.html` |
+| `/info/details.html` | `200 OK` | `public/info/details.html` |
+| `/other` | `302 Found` | `public/other/index.html` |
+| `/other/` | `200 OK` | `public/other/index.html` |
+| `/other/index` | `200 OK` | `public/other/index.html` |
+| `/other/index.html` | `200 OK` | `public/other/index.html` |
+
+NOTE: **Note:**
+When `public/data/index.html` exists, it takes priority over the `public/data.html`
+file for both the `/data` and `/data/` URL paths.
+
### Add a custom domain to your Pages website
For a complete guide on Pages domains, read through the article
diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md
index ac26aeab137..1213474b7d8 100644
--- a/doc/workflow/repository_mirroring.md
+++ b/doc/workflow/repository_mirroring.md
@@ -55,7 +55,7 @@ When push mirroring is enabled, only push commits directly to the mirrored repos
mirror diverging. All changes will end up in the mirrored repository whenever:
- Commits are pushed to GitLab.
-- A [forced update](#forcing-an-update) is initiated.
+- A [forced update](#forcing-an-update-core) is initiated.
Changes pushed to files in the repository are automatically pushed to the remote mirror at least:
@@ -122,7 +122,7 @@ directly to the repository on GitLab. Instead, any commits should be pushed to t
Changes pushed to the upstream repository will be pulled into the GitLab repository, either:
- Automatically within a certain period of time.
-- When a [forced update](#forcing-an-update) is initiated.
+- When a [forced update](#forcing-an-update-core) is initiated.
CAUTION: **Caution:**
If you do manually update a branch in the GitLab repository, the branch will become diverged from
@@ -259,7 +259,7 @@ failed. This will become visible in either the:
- Pull mirror settings page.
When a project is hard failed, it will no longer get picked up for mirroring. A user can resume the
-project mirroring again by [Forcing an update](#forcing-an-update).
+project mirroring again by [Forcing an update](#forcing-an-update-core).
### Trigger update using API **[STARTER]**
@@ -292,8 +292,8 @@ them and how they will be resolved.
Rewriting any mirrored commit on either remote will cause conflicts and mirroring to fail. This can
be prevented by:
-- [Pulling only protected branches](#pull-only-protected-branches).
-- [Pushing only protected branches](#push-only-protected-branches).
+- [Pulling only protected branches](#only-mirror-protected-branches-starter).
+- [Pushing only protected branches](#push-only-protected-branches-core).
You should [protect the branches](../user/project/protected_branches.md) you wish to mirror on both
remotes to prevent conflicts caused by rewriting history.
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 2b42e377c74..9cbfc0e35ff 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -9,6 +9,7 @@ module API
NO_SLASH_URL_PART_REGEX = %r{[^/]+}
NAMESPACE_OR_PROJECT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
COMMIT_ENDPOINT_REQUIREMENTS = NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze
+ USER_REQUIREMENTS = { user_id: NO_SLASH_URL_PART_REGEX }.freeze
insert_before Grape::Middleware::Error,
GrapeLogging::Middleware::RequestLogger,
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 9f1394571d8..a1f0efa3c68 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1116,7 +1116,9 @@ module API
class Release < TagRelease
expose :name
- expose :description_html
+ expose :description_html do |entity|
+ MarkupHelper.markdown_field(entity, :description)
+ end
expose :created_at
expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? }
expose :commit, using: Entities::Commit
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index fa6c9777824..e3d0b981065 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -422,7 +422,7 @@ module API
def present_disk_file!(path, filename, content_type = 'application/octet-stream')
filename ||= File.basename(path)
- header['Content-Disposition'] = "attachment; filename=#{filename}"
+ header['Content-Disposition'] = ::Gitlab::ContentDisposition.format(disposition: 'attachment', filename: filename)
header['Content-Transfer-Encoding'] = 'binary'
content_type content_type
@@ -496,7 +496,7 @@ module API
def send_git_blob(repository, blob)
env['api.format'] = :txt
content_type 'text/plain'
- header['Content-Disposition'] = content_disposition('inline', blob.name)
+ header['Content-Disposition'] = ::Gitlab::ContentDisposition.format(disposition: 'inline', filename: blob.name)
# Let Workhorse examine the content and determine the better content disposition
header[Gitlab::Workhorse::DETECT_HEADER] = "true"
@@ -533,11 +533,5 @@ module API
params[:archived]
end
-
- def content_disposition(disposition, filename)
- disposition += %(; filename=#{filename.inspect}) if filename.present?
-
- disposition
- end
end
end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index afa3ac80121..b3636c98550 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -43,7 +43,8 @@ module API
desc: 'Return issues sorted in `asc` or `desc` order.'
optional :milestone, type: String, desc: 'Return issues for a specific milestone'
optional :iids, type: Array[Integer], desc: 'The IID array of issues'
- optional :search, type: String, desc: 'Search issues for text present in the title or description'
+ optional :search, type: String, desc: 'Search issues for text present in the title, description, or any combination of these'
+ optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma'
optional :created_after, type: DateTime, desc: 'Return issues created after the specified time'
optional :created_before, type: DateTime, desc: 'Return issues created before the specified time'
optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time'
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 132b19164d0..4179aaa93a0 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -109,7 +109,8 @@ module API
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
optional :source_branch, type: String, desc: 'Return merge requests with the given source branch'
optional :target_branch, type: String, desc: 'Return merge requests with the given target branch'
- optional :search, type: String, desc: 'Search merge requests for text present in the title or description'
+ optional :search, type: String, desc: 'Search merge requests for text present in the title, description, or any combination of these'
+ optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma'
optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title'
use :pagination
end
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 9f3a1699146..6a93ef9f3ad 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -25,6 +25,9 @@ module API
projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled]
projects = projects.with_statistics if params[:statistics]
+ lang = params[:with_programming_language]
+ projects = projects.with_programming_language(lang) if lang
+
projects
end
@@ -91,6 +94,7 @@ module API
optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of'
optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature'
optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature'
+ optional :with_programming_language, type: String, desc: 'Limit to repositories which use the given programming language'
optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user'
use :optional_filter_params_ee
@@ -128,7 +132,7 @@ module API
end
end
- resource :users, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ resource :users, requirements: API::USER_REQUIREMENTS do
desc 'Get a user projects' do
success Entities::BasicProjectDetails
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index b41fce76df0..8ce09a8881b 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -133,10 +133,10 @@ module API
desc "Get the status of a user"
params do
- requires :id_or_username, type: String, desc: 'The ID or username of the user'
+ requires :user_id, type: String, desc: 'The ID or username of the user'
end
- get ":id_or_username/status" do
- user = find_user(params[:id_or_username])
+ get ":user_id/status", requirements: API::USER_REQUIREMENTS do
+ user = find_user(params[:user_id])
not_found!('User') unless user && can?(current_user, :read_user, user)
present user.status || {}, with: Entities::UserStatus
diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb
index f3061bad4ff..086adf59d2b 100644
--- a/lib/banzai/filter/autolink_filter.rb
+++ b/lib/banzai/filter/autolink_filter.rb
@@ -114,7 +114,11 @@ module Banzai
# Since this came from a Text node, make sure the new href is encoded.
# `commonmarker` percent encodes the domains of links it handles, so
# do the same (instead of using `normalized_encode`).
- href_safe = Addressable::URI.encode(match).html_safe
+ begin
+ href_safe = Addressable::URI.encode(match).html_safe
+ rescue Addressable::URI::InvalidURIError
+ return uri.to_s
+ end
html_safe_match = match.html_safe
options = link_options.merge(href: href_safe)
diff --git a/lib/banzai/filter/markdown_engines/redcarpet.rb b/lib/banzai/filter/markdown_engines/redcarpet.rb
deleted file mode 100644
index 5b3f75096b1..00000000000
--- a/lib/banzai/filter/markdown_engines/redcarpet.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-# `Redcarpet` markdown engine for GitLab's Banzai markdown filter.
-# This module is used in Banzai::Filter::MarkdownFilter.
-# Used gem is `redcarpet` which is a ruby library for markdown processing.
-# Homepage: https://github.com/vmg/redcarpet
-
-module Banzai
- module Filter
- module MarkdownEngines
- class Redcarpet
- OPTIONS = {
- fenced_code_blocks: true,
- footnotes: true,
- lax_spacing: true,
- no_intra_emphasis: true,
- space_after_headers: true,
- strikethrough: true,
- superscript: true,
- tables: true
- }.freeze
-
- def initialize(context = nil)
- html_renderer = Banzai::Renderer::Redcarpet::HTML.new
- @renderer = ::Redcarpet::Markdown.new(html_renderer, OPTIONS)
- end
-
- def render(text)
- @renderer.render(text)
- end
- end
- end
- end
-end
diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb
index 00dbf2d3130..50bf823929c 100644
--- a/lib/banzai/filter/spaced_link_filter.rb
+++ b/lib/banzai/filter/spaced_link_filter.rb
@@ -45,8 +45,6 @@ module Banzai
]).freeze
def call
- return doc if context[:markdown_engine] == :redcarpet
-
doc.xpath(TEXT_QUERY).each do |node|
content = node.to_html
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index bcf77861f10..9ffde52b5f2 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'rouge/plugins/common_mark'
-require 'rouge/plugins/redcarpet'
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai
diff --git a/lib/banzai/renderer/redcarpet/html.rb b/lib/banzai/renderer/redcarpet/html.rb
deleted file mode 100644
index 84931fdc784..00000000000
--- a/lib/banzai/renderer/redcarpet/html.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Banzai
- module Renderer
- module Redcarpet
- class HTML < ::Redcarpet::Render::HTML
- def block_code(code, lang)
- lang_attr = lang ? %Q{ lang="#{lang}"} : ''
-
- "\n<pre>" \
- "<code#{lang_attr}>#{ERB::Util.html_escape(code)}</code>" \
- "</pre>"
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb
index 42c657afe6a..15b9d5ad6e9 100644
--- a/lib/gitlab/auth/ldap/adapter.rb
+++ b/lib/gitlab/auth/ldap/adapter.rb
@@ -30,14 +30,7 @@ module Gitlab
def users(fields, value, limit = nil)
options = user_options(Array(fields), value, limit)
-
- entries = ldap_search(options).select do |entry|
- entry.respond_to? config.uid
- end
-
- entries.map do |entry|
- Gitlab::Auth::LDAP::Person.new(entry, provider)
- end
+ users_search(options)
end
def user(*args)
@@ -90,6 +83,16 @@ module Gitlab
SEARCH_RETRY_FACTOR[retry_number] * config.timeout
end
+ def users_search(options)
+ entries = ldap_search(options).select do |entry|
+ entry.respond_to? config.uid
+ end
+
+ entries.map do |entry|
+ Gitlab::Auth::LDAP::Person.new(entry, provider)
+ end
+ end
+
def user_options(fields, value, limit)
options = {
attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config),
diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb
index 6cf40e2d4ca..5251e0fadf9 100644
--- a/lib/gitlab/background_migration.rb
+++ b/lib/gitlab/background_migration.rb
@@ -16,11 +16,18 @@ module Gitlab
# re-raises the exception.
#
# steal_class - The name of the class for which to steal jobs.
- def self.steal(steal_class)
- enqueued = Sidekiq::Queue.new(self.queue)
- scheduled = Sidekiq::ScheduledSet.new
+ def self.steal(steal_class, retry_dead_jobs: false)
+ queues = [
+ Sidekiq::ScheduledSet.new,
+ Sidekiq::Queue.new(self.queue)
+ ]
+
+ if retry_dead_jobs
+ queues << Sidekiq::RetrySet.new
+ queues << Sidekiq::DeadSet.new
+ end
- [scheduled, enqueued].each do |queue|
+ queues.each do |queue|
queue.each do |job|
migration_class, migration_args = job.args
diff --git a/lib/gitlab/ci/pipeline/chain/limit/activity.rb b/lib/gitlab/ci/pipeline/chain/limit/activity.rb
new file mode 100644
index 00000000000..fe7c8738cc0
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/limit/activity.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ module Limit
+ class Activity < Chain::Base
+ def perform!
+ # to be overriden in EE
+ end
+
+ def break?
+ false # to be overriden in EE
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/chain/limit/size.rb b/lib/gitlab/ci/pipeline/chain/limit/size.rb
new file mode 100644
index 00000000000..b4d51437cd6
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/limit/size.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ module Limit
+ class Size < Chain::Base
+ def perform!
+ # to be overriden in EE
+ end
+
+ def break?
+ false # to be overriden in EE
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb
new file mode 100644
index 00000000000..0f687a4ce9b
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ class RemoveUnwantedChatJobs < Chain::Base
+ def perform!
+ # to be overriden in EE
+ end
+
+ def break?
+ false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/content_disposition.rb b/lib/gitlab/content_disposition.rb
new file mode 100644
index 00000000000..32207514ce5
--- /dev/null
+++ b/lib/gitlab/content_disposition.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+# This ports ActionDispatch::Http::ContentDisposition (https://github.com/rails/rails/pull/33829,
+# which will be available in Rails 6.
+module Gitlab
+ class ContentDisposition # :nodoc:
+ # Make sure we remove this patch starting with Rails 6.0.
+ if Rails.version.start_with?('6.0')
+ raise <<~MSG
+ Please remove this file and use `ActionDispatch::Http::ContentDisposition` instead.
+ MSG
+ end
+
+ def self.format(disposition:, filename:)
+ new(disposition: disposition, filename: filename).to_s
+ end
+
+ attr_reader :disposition, :filename
+
+ def initialize(disposition:, filename:)
+ @disposition = disposition
+ @filename = filename
+ end
+
+ # rubocop:disable Style/VariableInterpolation
+ TRADITIONAL_ESCAPED_CHAR = /[^ A-Za-z0-9!#$+.^_`|~-]/
+
+ def ascii_filename
+ 'filename="' + percent_escape(::I18n.transliterate(filename), TRADITIONAL_ESCAPED_CHAR) + '"'
+ end
+
+ RFC_5987_ESCAPED_CHAR = /[^A-Za-z0-9!#$&+.^_`|~-]/
+ # rubocop:enable Style/VariableInterpolation
+
+ def utf8_filename
+ "filename*=UTF-8''" + percent_escape(filename, RFC_5987_ESCAPED_CHAR)
+ end
+
+ def to_s
+ if filename
+ "#{disposition}; #{ascii_filename}; #{utf8_filename}"
+ else
+ "#{disposition}"
+ end
+ end
+
+ private
+
+ def percent_escape(string, pattern)
+ string.gsub(pattern) do |char|
+ char.bytes.map { |byte| "%%%02X" % byte }.join
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index 7987533978c..099677a791c 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -130,9 +130,14 @@ excluded_attributes:
snippets:
- :expired_at
merge_request_diff:
+ - :external_diff
+ - :stored_externally
+ - :external_diff_store
- :st_diffs
merge_request_diff_files:
- :diff
+ - :external_diff_offset
+ - :external_diff_size
issues:
- :milestone_id
merge_requests:
@@ -166,6 +171,7 @@ excluded_attributes:
error_tracking_setting:
- :encrypted_token
- :encrypted_token_iv
+ - :enabled
methods:
labels:
diff --git a/lib/gitlab/patch/sprockets_base_file_digest_key.rb b/lib/gitlab/patch/sprockets_base_file_digest_key.rb
new file mode 100644
index 00000000000..3925cdbbada
--- /dev/null
+++ b/lib/gitlab/patch/sprockets_base_file_digest_key.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# This monkey patch prevent cache ballooning when caching tmp/cache/assets/sprockets
+# on the CI. See https://github.com/rails/sprockets/issues/563 and
+# https://github.com/rails/sprockets/compare/3.x...jmreid:no-mtime-for-digest-key.
+module Gitlab
+ module Patch
+ module SprocketsBaseFileDigestKey
+ def file_digest(path)
+ if stat = self.stat(path)
+ digest = self.stat_digest(path, stat)
+ integrity_uri = self.hexdigest_integrity_uri(digest)
+
+ key = Sprockets::UnloadedAsset.new(path, self).file_digest_key(integrity_uri)
+ cache.fetch(key) do
+ digest
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/task_helpers.rb b/lib/gitlab/task_helpers.rb
index 224bb648d8f..8532845f3cb 100644
--- a/lib/gitlab/task_helpers.rb
+++ b/lib/gitlab/task_helpers.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rainbow/ext/string'
-require 'gitlab/utils/strong_memoize'
+require_dependency 'gitlab/utils/strong_memoize'
# rubocop:disable Rails/Output
module Gitlab
@@ -13,6 +13,12 @@ module Gitlab
extend self
+ def invoke_and_time_task(task)
+ start = Time.now
+ Rake::Task[task].invoke
+ puts "`#{task}` finished in #{Time.now - start} seconds"
+ end
+
# Ask if the user wants to continue
#
# Returns "yes" the user chose to continue
diff --git a/lib/gitlab/utils/merge_hash.rb b/lib/gitlab/utils/merge_hash.rb
index fc237861e2f..48ba13b8561 100644
--- a/lib/gitlab/utils/merge_hash.rb
+++ b/lib/gitlab/utils/merge_hash.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require_dependency 'gitlab/utils'
+
module Gitlab
module Utils
module MergeHash
diff --git a/lib/gitlab/utils/override.rb b/lib/gitlab/utils/override.rb
index c87e97d0213..f5299439fce 100644
--- a/lib/gitlab/utils/override.rb
+++ b/lib/gitlab/utils/override.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require_dependency 'gitlab/utils'
+
module Gitlab
module Utils
module Override
diff --git a/lib/gitlab/utils/strong_memoize.rb b/lib/gitlab/utils/strong_memoize.rb
index aa1f8e2fdda..3021a91dd83 100644
--- a/lib/gitlab/utils/strong_memoize.rb
+++ b/lib/gitlab/utils/strong_memoize.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require_dependency 'gitlab/utils'
+
module Gitlab
module Utils
module StrongMemoize
diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake
index a42f02a84fd..7a42e4e92a0 100644
--- a/lib/tasks/gitlab/assets.rake
+++ b/lib/tasks/gitlab/assets.rake
@@ -1,13 +1,17 @@
namespace :gitlab do
namespace :assets do
desc 'GitLab | Assets | Compile all frontend assets'
- task compile: [
- 'yarn:check',
- 'gettext:po_to_json',
- 'rake:assets:precompile',
- 'webpack:compile',
- 'fix_urls'
- ]
+ task :compile do
+ require_dependency 'gitlab/task_helpers'
+
+ %w[
+ yarn:check
+ gettext:po_to_json
+ rake:assets:precompile
+ webpack:compile
+ gitlab:assets:fix_urls
+ ].each(&Gitlab::TaskHelpers.method(:invoke_and_time_task))
+ end
desc 'GitLab | Assets | Clean up old compiled frontend assets'
task clean: ['rake:assets:clean']
diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake
index e96fbb64372..3a1a36e36ce 100644
--- a/lib/tasks/gitlab/backup.rake
+++ b/lib/tasks/gitlab/backup.rake
@@ -47,9 +47,9 @@ namespace :gitlab do
# Drop all tables Load the schema to ensure we don't have any newer tables
# hanging out from a failed upgrade
- progress.puts 'Cleaning the database ... '.color(:blue)
+ puts_time 'Cleaning the database ... '.color(:blue)
Rake::Task['gitlab:db:drop_tables'].invoke
- progress.puts 'done'.color(:green)
+ puts_time 'done'.color(:green)
Rake::Task['gitlab:backup:db:restore'].invoke
rescue Gitlab::TaskAbortedByUserError
puts "Quitting...".color(:red)
@@ -72,165 +72,169 @@ namespace :gitlab do
namespace :repo do
task create: :gitlab_environment do
- progress.puts "Dumping repositories ...".color(:blue)
+ puts_time "Dumping repositories ...".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("repositories")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Repository.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring repositories ...".color(:blue)
+ puts_time "Restoring repositories ...".color(:blue)
Backup::Repository.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :db do
task create: :gitlab_environment do
- progress.puts "Dumping database ... ".color(:blue)
+ puts_time "Dumping database ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("db")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Database.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring database ... ".color(:blue)
+ puts_time "Restoring database ... ".color(:blue)
Backup::Database.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :builds do
task create: :gitlab_environment do
- progress.puts "Dumping builds ... ".color(:blue)
+ puts_time "Dumping builds ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("builds")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Builds.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring builds ... ".color(:blue)
+ puts_time "Restoring builds ... ".color(:blue)
Backup::Builds.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :uploads do
task create: :gitlab_environment do
- progress.puts "Dumping uploads ... ".color(:blue)
+ puts_time "Dumping uploads ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("uploads")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Uploads.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring uploads ... ".color(:blue)
+ puts_time "Restoring uploads ... ".color(:blue)
Backup::Uploads.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :artifacts do
task create: :gitlab_environment do
- progress.puts "Dumping artifacts ... ".color(:blue)
+ puts_time "Dumping artifacts ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("artifacts")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Artifacts.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring artifacts ... ".color(:blue)
+ puts_time "Restoring artifacts ... ".color(:blue)
Backup::Artifacts.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :pages do
task create: :gitlab_environment do
- progress.puts "Dumping pages ... ".color(:blue)
+ puts_time "Dumping pages ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("pages")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Pages.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring pages ... ".color(:blue)
+ puts_time "Restoring pages ... ".color(:blue)
Backup::Pages.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :lfs do
task create: :gitlab_environment do
- progress.puts "Dumping lfs objects ... ".color(:blue)
+ puts_time "Dumping lfs objects ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("lfs")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Lfs.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring lfs objects ... ".color(:blue)
+ puts_time "Restoring lfs objects ... ".color(:blue)
Backup::Lfs.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
end
namespace :registry do
task create: :gitlab_environment do
- progress.puts "Dumping container registry images ... ".color(:blue)
+ puts_time "Dumping container registry images ... ".color(:blue)
if Gitlab.config.registry.enabled
if ENV["SKIP"] && ENV["SKIP"].include?("registry")
- progress.puts "[SKIPPED]".color(:cyan)
+ puts_time "[SKIPPED]".color(:cyan)
else
Backup::Registry.new(progress).dump
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
end
else
- progress.puts "[DISABLED]".color(:cyan)
+ puts_time "[DISABLED]".color(:cyan)
end
end
task restore: :gitlab_environment do
- progress.puts "Restoring container registry images ... ".color(:blue)
+ puts_time "Restoring container registry images ... ".color(:blue)
if Gitlab.config.registry.enabled
Backup::Registry.new(progress).restore
- progress.puts "done".color(:green)
+ puts_time "done".color(:green)
else
- progress.puts "[DISABLED]".color(:cyan)
+ puts_time "[DISABLED]".color(:cyan)
end
end
end
+ def puts_time(msg)
+ progress.puts "#{Time.now} -- #{msg}"
+ end
+
def progress
if ENV['CRON']
# We need an object we can say 'puts' and 'print' to; let's use a
diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake
index 74cd70c6e9f..b94b21775ee 100644
--- a/lib/tasks/gitlab/db.rake
+++ b/lib/tasks/gitlab/db.rake
@@ -29,10 +29,11 @@ namespace :gitlab do
# If MySQL, turn off foreign key checks
connection.execute('SET FOREIGN_KEY_CHECKS=0') if Gitlab::Database.mysql?
- tables = connection.tables
+ tables = connection.data_sources
+ # Removes the entry from the array
tables.delete 'schema_migrations'
# Truncate schema_migrations to ensure migrations re-run
- connection.execute('TRUNCATE schema_migrations')
+ connection.execute('TRUNCATE schema_migrations') if connection.data_source_exists? 'schema_migrations'
# Drop tables with cascade to avoid dependent table errors
# PG: http://www.postgresql.org/docs/current/static/ddl-depend.html
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index bb98fc06ed6..1caf263b9f3 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -552,9 +552,6 @@ msgstr ""
msgid "An empty GitLab User field will add the FogBugz user's full name (e.g. \"By John Smith\") in the description of all issues and comments. It will also associate and/or assign these issues and comments with the project creator."
msgstr ""
-msgid "An error accured whilst committing your changes."
-msgstr ""
-
msgid "An error has occurred"
msgstr ""
@@ -639,6 +636,9 @@ msgstr ""
msgid "An error occurred while validating username"
msgstr ""
+msgid "An error occurred whilst committing your changes."
+msgstr ""
+
msgid "An error occurred whilst fetching the job trace."
msgstr ""
@@ -2426,6 +2426,9 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Data is still calculating..."
+msgstr ""
+
msgid "Debug"
msgstr ""
@@ -2620,6 +2623,9 @@ msgstr ""
msgid "DeployTokens|Your new project deploy token has been created."
msgstr ""
+msgid "Deployed"
+msgstr ""
+
msgid "Deployed to"
msgstr ""
@@ -2707,6 +2713,9 @@ msgstr ""
msgid "Dismiss"
msgstr ""
+msgid "Dismiss ConvDev introduction"
+msgstr ""
+
msgid "Dismiss Cycle Analytics introduction box"
msgstr ""
@@ -2992,6 +3001,9 @@ msgstr ""
msgid "Error Tracking"
msgstr ""
+msgid "Error deleting %{issuableType}"
+msgstr ""
+
msgid "Error fetching contributors data."
msgstr ""
@@ -3040,6 +3052,9 @@ msgstr ""
msgid "Error saving label update."
msgstr ""
+msgid "Error updating %{issuableType}"
+msgstr ""
+
msgid "Error updating status for all todos."
msgstr ""
@@ -3831,6 +3846,9 @@ msgstr ""
msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}."
msgstr ""
+msgid "In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index."
+msgstr ""
+
msgid "In the next step, you'll be able to select the projects you want to import."
msgstr ""
@@ -3900,6 +3918,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "Introducing Your Conversational Development Index"
+msgstr ""
+
msgid "Invitation"
msgstr ""
@@ -4139,6 +4160,9 @@ msgstr ""
msgid "Latest pipeline for this branch"
msgstr ""
+msgid "Lead"
+msgstr ""
+
msgid "Learn more"
msgstr ""
@@ -4372,9 +4396,15 @@ msgstr ""
msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
msgstr ""
+msgid "MergeRequests|Add a reply"
+msgstr ""
+
msgid "MergeRequests|Jump to next unresolved discussion"
msgstr ""
+msgid "MergeRequests|Reply..."
+msgstr ""
+
msgid "MergeRequests|Resolve this discussion in a new issue"
msgstr ""
@@ -4411,10 +4441,10 @@ msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
msgstr ""
-msgid "MergeRequest|Filter files"
+msgid "MergeRequest|No files found"
msgstr ""
-msgid "MergeRequest|No files found"
+msgid "MergeRequest|Search files"
msgstr ""
msgid "Merged"
@@ -4447,9 +4477,6 @@ msgstr ""
msgid "Metrics|Learn about environments"
msgstr ""
-msgid "Metrics|No data to display"
-msgstr ""
-
msgid "Metrics|No deployed environments"
msgstr ""
@@ -4812,9 +4839,6 @@ msgstr ""
msgid "Notes|Show history only"
msgstr ""
-msgid "Nothing here."
-msgstr ""
-
msgid "Notification events"
msgstr ""
@@ -5516,6 +5540,9 @@ msgstr ""
msgid "Profiles|Username successfully changed"
msgstr ""
+msgid "Profiles|Using emojis in names seems fun, but please try to set a status message instead"
+msgstr ""
+
msgid "Profiles|What's your status?"
msgstr ""
@@ -5711,9 +5738,6 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
-msgid "PrometheusDashboard|Time"
-msgstr ""
-
msgid "PrometheusService|%{exporters} with %{metrics} were found"
msgstr ""
@@ -5935,6 +5959,9 @@ msgstr ""
msgid "Reopen milestone"
msgstr ""
+msgid "Reply to comment"
+msgstr ""
+
msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr ""
@@ -6336,9 +6363,6 @@ msgstr ""
msgid "Serverless"
msgstr ""
-msgid "ServerlessDetails|Copy URL to clipboard"
-msgstr ""
-
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
@@ -6351,19 +6375,13 @@ msgstr ""
msgid "ServerlessDetails|pods in use"
msgstr ""
-msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
+msgid "ServerlessURL|Copy URL to clipboard"
msgstr ""
-msgid "Serverless|An error occurred while retrieving serverless components"
-msgstr ""
-
-msgid "Serverless|Cluster Env"
-msgstr ""
-
-msgid "Serverless|Description"
+msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
-msgid "Serverless|Function"
+msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Getting started with serverless"
@@ -6375,18 +6393,12 @@ msgstr ""
msgid "Serverless|Install Knative"
msgstr ""
-msgid "Serverless|Last Update"
-msgstr ""
-
msgid "Serverless|Learn more about Serverless"
msgstr ""
msgid "Serverless|No functions available"
msgstr ""
-msgid "Serverless|Runtime"
-msgstr ""
-
msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:"
msgstr ""
@@ -6530,6 +6542,9 @@ msgstr ""
msgid "Snippets"
msgstr ""
+msgid "Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again."
+msgstr ""
+
msgid "Something went wrong on our end"
msgstr ""
@@ -7656,6 +7671,9 @@ msgstr ""
msgid "Undo"
msgstr ""
+msgid "Unfortunately, your email message to GitLab could not be processed."
+msgstr ""
+
msgid "Unlock"
msgstr ""
@@ -7716,6 +7734,9 @@ msgstr ""
msgid "Update"
msgstr ""
+msgid "Update failed"
+msgstr ""
+
msgid "Update now"
msgstr ""
@@ -7800,12 +7821,24 @@ msgstr ""
msgid "UserProfile|Edit profile"
msgstr ""
+msgid "UserProfile|Explore public groups to find projects to contribute to."
+msgstr ""
+
msgid "UserProfile|Groups"
msgstr ""
+msgid "UserProfile|Groups are the best way to manage projects and members."
+msgstr ""
+
+msgid "UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!"
+msgstr ""
+
msgid "UserProfile|Most Recent Activity"
msgstr ""
+msgid "UserProfile|No snippets found."
+msgstr ""
+
msgid "UserProfile|Overview"
msgstr ""
@@ -7818,6 +7851,9 @@ msgstr ""
msgid "UserProfile|Snippets"
msgstr ""
+msgid "UserProfile|Snippets in GitLab can either be private, internal, or public."
+msgstr ""
+
msgid "UserProfile|Subscribe"
msgstr ""
@@ -7827,12 +7863,27 @@ msgstr ""
msgid "UserProfile|This user has a private profile"
msgstr ""
+msgid "UserProfile|This user hasn't contributed to any projects"
+msgstr ""
+
msgid "UserProfile|View all"
msgstr ""
msgid "UserProfile|View user in admin area"
msgstr ""
+msgid "UserProfile|You can create a group for several dependent projects."
+msgstr ""
+
+msgid "UserProfile|You haven't created any personal projects."
+msgstr ""
+
+msgid "UserProfile|You haven't created any snippets."
+msgstr ""
+
+msgid "UserProfile|Your projects can be available publicly, internally, or privately, at your choice."
+msgstr ""
+
msgid "Users"
msgstr ""
@@ -8130,6 +8181,9 @@ msgstr ""
msgid "Yesterday"
msgstr ""
+msgid "You"
+msgstr ""
+
msgid "You are an admin, which means granting access to <strong>%{client_name}</strong> will allow them to interact with GitLab as an admin as well. Proceed with caution."
msgstr ""
@@ -8268,6 +8322,9 @@ msgstr ""
msgid "YouTube"
msgstr ""
+msgid "Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers."
+msgstr ""
+
msgid "Your Groups"
msgstr ""
@@ -8434,6 +8491,9 @@ msgstr ""
msgid "in project %{link_to_project}"
msgstr ""
+msgid "index"
+msgstr ""
+
msgid "issue boards"
msgstr ""
@@ -8673,6 +8733,15 @@ msgstr ""
msgid "notification emails"
msgstr ""
+msgid "nounSeries|%{firstItem} and %{lastItem}"
+msgstr ""
+
+msgid "nounSeries|%{item}, %{nextItem}"
+msgstr ""
+
+msgid "nounSeries|%{item}, and %{lastItem}"
+msgstr ""
+
msgid "or"
msgstr ""
@@ -8718,6 +8787,9 @@ msgid_plural "replies"
msgstr[0] ""
msgstr[1] ""
+msgid "score"
+msgstr ""
+
msgid "should be higher than %{access} inherited membership from group %{group_name}"
msgstr ""
diff --git a/package.json b/package.json
index 13c0527c4a3..97d8fd3b17f 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"@babel/preset-env": "^7.3.1",
"@gitlab/csslab": "^1.8.0",
"@gitlab/svgs": "^1.48.0",
- "@gitlab/ui": "^1.22.1",
+ "@gitlab/ui": "^2.0.2",
"apollo-boost": "^0.1.20",
"apollo-client": "^2.4.5",
"autosize": "^4.0.0",
@@ -56,11 +56,12 @@
"d3-time": "^1.0.8",
"d3-time-format": "^2.1.1",
"dateformat": "^3.0.3",
- "deckar01-task_list": "^2.0.1",
+ "deckar01-task_list": "^2.2.0",
"diff": "^3.4.0",
- "document-register-element": "1.3.0",
+ "document-register-element": "1.13.1",
"dropzone": "^4.2.0",
"echarts": "^4.2.0-rc.2",
+ "emoji-regex": "^7.0.3",
"emoji-unicode-version": "^0.2.1",
"exports-loader": "^0.7.0",
"file-loader": "^3.0.1",
diff --git a/qa/qa.rb b/qa/qa.rb
index 355034daec9..8c85513198b 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -99,6 +99,7 @@ module QA
autoload :LDAPNoTLS, 'qa/scenario/test/integration/ldap_no_tls'
autoload :LDAPTLS, 'qa/scenario/test/integration/ldap_tls'
autoload :InstanceSAML, 'qa/scenario/test/integration/instance_saml'
+ autoload :OAuth, 'qa/scenario/test/integration/oauth'
autoload :Kubernetes, 'qa/scenario/test/integration/kubernetes'
autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
autoload :ObjectStorage, 'qa/scenario/test/integration/object_storage'
@@ -273,9 +274,11 @@ module QA
module Settings
autoload :Repository, 'qa/page/admin/settings/repository'
+ autoload :General, 'qa/page/admin/settings/general'
module Component
autoload :RepositoryStorage, 'qa/page/admin/settings/component/repository_storage'
+ autoload :AccountAndLimit, 'qa/page/admin/settings/component/account_and_limit'
end
end
end
@@ -342,6 +345,13 @@ module QA
autoload :Login, 'qa/vendor/saml_idp/page/login'
end
end
+
+ module Github
+ module Page
+ autoload :Base, 'qa/vendor/github/page/base'
+ autoload :Login, 'qa/vendor/github/page/login'
+ end
+ end
end
# Classes that provide support to other parts of the framework.
diff --git a/qa/qa/page/admin/menu.rb b/qa/qa/page/admin/menu.rb
index e8c7d274966..25564f2dc6e 100644
--- a/qa/qa/page/admin/menu.rb
+++ b/qa/qa/page/admin/menu.rb
@@ -9,6 +9,7 @@ module QA
element :admin_sidebar_submenu
element :admin_settings_item
element :admin_settings_repository_item
+ element :admin_settings_general_item
end
def go_to_repository_settings
@@ -19,6 +20,14 @@ module QA
end
end
+ def go_to_general_settings
+ hover_settings do
+ within_submenu do
+ click_element :admin_settings_general_item
+ end
+ end
+ end
+
private
def hover_settings
diff --git a/qa/qa/page/admin/settings/component/account_and_limit.rb b/qa/qa/page/admin/settings/component/account_and_limit.rb
new file mode 100644
index 00000000000..a61c8cc77cd
--- /dev/null
+++ b/qa/qa/page/admin/settings/component/account_and_limit.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Admin
+ module Settings
+ module Component
+ class AccountAndLimit < Page::Base
+ view 'app/views/admin/application_settings/_account_and_limit.html.haml' do
+ element :receive_max_input_size_field
+ element :save_changes_button
+ end
+
+ def set_max_file_size(size)
+ fill_element :receive_max_input_size_field, size
+ end
+
+ def save_settings
+ click_element :save_changes_button
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/admin/settings/general.rb b/qa/qa/page/admin/settings/general.rb
new file mode 100644
index 00000000000..93b290f7e03
--- /dev/null
+++ b/qa/qa/page/admin/settings/general.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Admin
+ module Settings
+ class General < Page::Base
+ include QA::Page::Settings::Common
+
+ view 'app/views/admin/application_settings/show.html.haml' do
+ element :account_and_limit_settings
+ end
+
+ def expand_account_and_limit(&block)
+ expand_section(:account_and_limit_settings) do
+ Component::AccountAndLimit.perform(&block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb
index cb83ace20b6..d5377f1d1c1 100644
--- a/qa/qa/page/main/login.rb
+++ b/qa/qa/page/main/login.rb
@@ -31,8 +31,9 @@ module QA
element :register_tab
end
- view 'app/views/devise/shared/_omniauth_box.html.haml' do
+ view 'app/helpers/auth_helper.rb' do
element :saml_login_button
+ element :github_login_button
end
view 'app/views/layouts/devise.html.haml' do
@@ -132,6 +133,16 @@ module QA
click_element :standard_tab
end
+ def sign_in_with_github
+ set_initial_password_if_present
+ click_element :github_login_button
+ end
+
+ def sign_in_with_saml
+ set_initial_password_if_present
+ click_element :saml_login_button
+ end
+
private
def sign_in_using_ldap_credentials
@@ -142,11 +153,6 @@ module QA
click_element :sign_in_button
end
- def sign_in_with_saml
- set_initial_password_if_present
- click_element :saml_login_button
- end
-
def sign_in_using_gitlab_credentials(user)
switch_to_sign_in_tab if has_sign_in_tab?
switch_to_standard_tab if has_standard_tab?
diff --git a/qa/qa/page/project/job/show.rb b/qa/qa/page/project/job/show.rb
index d688f15914c..49c676c01f2 100644
--- a/qa/qa/page/project/job/show.rb
+++ b/qa/qa/page/project/job/show.rb
@@ -16,11 +16,19 @@ module QA::Page
element :status_badge
end
+ view 'app/assets/javascripts/jobs/components/stages_dropdown.vue' do
+ element :pipeline_path
+ end
+
def completed?
COMPLETED_STATUSES.include?(status_badge)
end
- def passed?
+ def successful?(timeout: 60)
+ wait(reload: false, max: timeout) do
+ completed? && !trace_loading?
+ end
+
status_badge == PASSED_STATUS
end
diff --git a/qa/qa/page/project/pipeline/show.rb b/qa/qa/page/project/pipeline/show.rb
index b22396fd67a..f192f1fc64b 100644
--- a/qa/qa/page/project/pipeline/show.rb
+++ b/qa/qa/page/project/pipeline/show.rb
@@ -11,7 +11,7 @@ module QA::Page
view 'app/assets/javascripts/pipelines/components/graph/job_item.vue' do
element :job_component, /class.*ci-job-component.*/ # rubocop:disable QA/ElementWithPattern
- element :job_link, /class.*js-pipeline-graph-job-link.*/ # rubocop:disable QA/ElementWithPattern
+ element :job_link
end
view 'app/assets/javascripts/vue_shared/components/ci_icon.vue' do
@@ -32,6 +32,10 @@ module QA::Page
end
end
+ def go_to_job(job_name)
+ find_element(:job_link, job_name).click
+ end
+
def go_to_first_job
css = '.js-pipeline-graph-job-link'
diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb
index 23a2ace6a55..dd0ddbdbd6b 100644
--- a/qa/qa/runtime/env.rb
+++ b/qa/qa/runtime/env.rb
@@ -100,6 +100,14 @@ module QA
ENV['GITLAB_ADMIN_PASSWORD']
end
+ def github_username
+ ENV['GITHUB_USERNAME']
+ end
+
+ def github_password
+ ENV['GITHUB_PASSWORD']
+ end
+
def forker?
!!(forker_username && forker_password)
end
diff --git a/qa/qa/scenario/test/integration/oauth.rb b/qa/qa/scenario/test/integration/oauth.rb
new file mode 100644
index 00000000000..912156fbc29
--- /dev/null
+++ b/qa/qa/scenario/test/integration/oauth.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module QA
+ module Scenario
+ module Test
+ module Integration
+ class OAuth < Test::Instance::All
+ tags :oauth
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb
new file mode 100644
index 00000000000..a118176eb8a
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module QA
+ context 'Manage', :orchestrated, :oauth do
+ describe 'OAuth login' do
+ it 'User logs in to GitLab with GitHub OAuth' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+
+ Page::Main::Login.perform(&:sign_in_with_github)
+ Vendor::Github::Page::Login.perform(&:login)
+
+ expect(page).to have_content('Welcome to GitLab')
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb
new file mode 100644
index 00000000000..23ea55c2e61
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module QA
+ context 'Create' do
+ describe 'push after setting the file size limit via admin/application_settings' do
+ before(:all) do
+ push = Resource::Repository::ProjectPush.fabricate! do |p|
+ p.file_name = 'README.md'
+ p.file_content = '# This is a test project'
+ p.commit_message = 'Add README.md'
+ end
+
+ @project = push.project
+ end
+
+ before do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.perform(&:sign_in_using_credentials)
+ end
+
+ after(:all) do
+ # need to set the default value after test
+ # default value for file size limit is empty
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.perform(&:sign_in_using_credentials)
+
+ set_file_size_limit('')
+ end
+
+ it 'push successful when the file size is under the limit' do
+ set_file_size_limit(5)
+ expect(page).to have_content("Application settings saved successfully")
+
+ push = push_new_file('oversize_file_1.bin')
+ expect(push.output).not_to have_content 'remote: fatal: pack exceeds maximum allowed size'
+ end
+
+ it 'push fails when the file size is above the limit' do
+ set_file_size_limit(1)
+ expect(page).to have_content("Application settings saved successfully")
+
+ push = push_new_file('oversize_file_2.bin')
+ expect(push.output).to have_content 'remote: fatal: pack exceeds maximum allowed size'
+ end
+
+ def set_file_size_limit(limit)
+ Page::Main::Menu.perform(&:go_to_admin_area)
+ Page::Admin::Menu.perform(&:go_to_general_settings)
+
+ Page::Admin::Settings::General.perform do |setting|
+ setting.expand_account_and_limit do |page|
+ page.set_max_file_size(limit)
+ page.save_settings
+ end
+ end
+ end
+
+ def push_new_file(file_name)
+ @project.visit!
+
+ Resource::Repository::ProjectPush.fabricate! do |p|
+ p.project = @project
+ p.file_name = file_name
+ p.file_content = SecureRandom.random_bytes(2000000)
+ p.commit_message = 'Adding a new file'
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
index e2320c92343..11a9653db81 100644
--- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
@@ -95,11 +95,7 @@ module QA
Page::Project::Pipeline::Show.act { go_to_first_job }
Page::Project::Job::Show.perform do |job|
- job.wait(reload: false) do
- job.completed? && !job.trace_loading?
- end
-
- expect(job.passed?).to be_truthy, "Job status did not become \"passed\"."
+ expect(job).to be_successful, "Job status did not become \"passed\"."
expect(job.output).to include(sha1sum)
end
end
diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
index 553550eef8b..b0ff83db86b 100644
--- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
+++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
@@ -75,9 +75,30 @@ module QA
Page::Project::Pipeline::Index.act { go_to_latest_pipeline }
Page::Project::Pipeline::Show.perform do |pipeline|
- expect(pipeline).to have_build('build', status: :success, wait: 600)
- expect(pipeline).to have_build('test', status: :success, wait: 600)
- expect(pipeline).to have_build('production', status: :success, wait: 1200)
+ pipeline.go_to_job('build')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('test')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('production')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 1200), "Job did not pass"
+
+ job.click_element(:pipeline_path)
end
Page::Project::Menu.act { click_operations_environments }
@@ -115,9 +136,30 @@ module QA
Page::Project::Pipeline::Index.act { go_to_latest_pipeline }
Page::Project::Pipeline::Show.perform do |pipeline|
- expect(pipeline).to have_build('build', status: :success, wait: 600)
- expect(pipeline).to have_build('test', status: :success, wait: 600)
- expect(pipeline).to have_build('production', status: :success, wait: 1200)
+ pipeline.go_to_job('build')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('test')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 600), "Job did not pass"
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.go_to_job('production')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_sucessful(timeout: 1200), "Job did not pass"
+
+ job.click_element(:pipeline_path)
end
Page::Project::Menu.act { click_operations_environments }
diff --git a/qa/qa/vendor/github/page/base.rb b/qa/qa/vendor/github/page/base.rb
new file mode 100644
index 00000000000..3b96180afe9
--- /dev/null
+++ b/qa/qa/vendor/github/page/base.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module QA
+ module Vendor
+ module Github
+ module Page
+ class Base
+ include Capybara::DSL
+ include Scenario::Actable
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/vendor/github/page/login.rb b/qa/qa/vendor/github/page/login.rb
new file mode 100644
index 00000000000..6d8f9aa7c12
--- /dev/null
+++ b/qa/qa/vendor/github/page/login.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'capybara/dsl'
+
+module QA
+ module Vendor
+ module Github
+ module Page
+ class Login < Page::Base
+ def login
+ fill_in 'login', with: QA::Runtime::Env.github_username
+ fill_in 'password', with: QA::Runtime::Env.github_password
+ click_on 'Sign in'
+
+ unless has_no_text?("Authorize GitLab-OAuth")
+ click_on 'Authorize gitlab-qa' if has_button?('Authorize gitlab-qa')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/spec/scenario/test/integration/oauth_spec.rb b/qa/spec/scenario/test/integration/oauth_spec.rb
new file mode 100644
index 00000000000..c1c320be576
--- /dev/null
+++ b/qa/spec/scenario/test/integration/oauth_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+describe QA::Scenario::Test::Integration::OAuth do
+ context '#perform' do
+ it_behaves_like 'a QA scenario class' do
+ let(:tags) { [:oauth] }
+ end
+ end
+end
diff --git a/scripts/clean-old-cached-assets b/scripts/clean-old-cached-assets
new file mode 100755
index 00000000000..7a3a62a477a
--- /dev/null
+++ b/scripts/clean-old-cached-assets
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+# Clean up cached files that are older than 1 week
+find tmp/cache/assets/sprockets/ -type f -mtime +7 -execdir rm -- "{}" \;
+
+du -d 0 -h tmp/cache/assets/sprockets | cut -f1 | xargs -I % echo "tmp/cache/assets/sprockets/ is currently %"
diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh
index 6e0dee9e090..f610485a700 100755
--- a/scripts/review_apps/review-apps.sh
+++ b/scripts/review_apps/review-apps.sh
@@ -143,11 +143,16 @@ HELM_CMD=$(cat << EOF
--set global.hosts.hostSuffix="$HOST_SUFFIX" \
--set global.hosts.domain="$REVIEW_APPS_DOMAIN" \
--set certmanager.install=false \
+ --set prometheus.install=false \
--set global.ingress.configureCertmanager=false \
--set global.ingress.tls.secretName=tls-cert \
--set global.ingress.annotations."external-dns\.alpha\.kubernetes\.io/ttl"="10"
+ --set nginx-ingress.defaultBackend.resources.requests.memory=7Mi \
+ --set nginx-ingress.controller.resources.requests.memory=440M \
+ --set nginx-ingress.controller.replicaCount=2 \
--set gitlab.unicorn.resources.requests.cpu=200m \
--set gitlab.sidekiq.resources.requests.cpu=100m \
+ --set gitlab.sidekiq.resources.requests.memory=800M \
--set gitlab.gitlab-shell.resources.requests.cpu=100m \
--set redis.resources.requests.cpu=100m \
--set minio.resources.requests.cpu=100m \
diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb
index 379b2d6b935..a07113a6156 100644
--- a/spec/controllers/concerns/send_file_upload_spec.rb
+++ b/spec/controllers/concerns/send_file_upload_spec.rb
@@ -53,19 +53,38 @@ describe SendFileUpload do
end
context 'with attachment' do
- let(:params) { { attachment: 'test.js' } }
+ let(:filename) { 'test.js' }
+ let(:params) { { attachment: filename } }
it 'sends a file with content-type of text/plain' do
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
expected_params = {
content_type: 'text/plain',
filename: 'test.js',
- disposition: 'attachment'
+ disposition: "attachment; filename*=UTF-8''test.js"
}
expect(controller).to receive(:send_file).with(uploader.path, expected_params)
subject
end
+ context 'with non-ASCII encoded filename' do
+ let(:filename) { 'テスト.txt' }
+
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ it 'sends content-disposition for non-ASCII encoded filenames' do
+ expected_params = {
+ filename: filename,
+ disposition: "attachment; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt"
+ }
+ expect(controller).to receive(:send_file).with(uploader.path, expected_params)
+
+ subject
+ end
+ end
+
context 'with a proxied file in object storage' do
before do
stub_uploads_object_storage(uploader: uploader_class)
@@ -76,7 +95,7 @@ describe SendFileUpload do
it 'sends a file with a custom type' do
headers = double
- expected_headers = %r(response-content-disposition=attachment%3Bfilename%3D%22test.js%22&response-content-type=application/ecmascript)
+ expected_headers = %r(response-content-disposition=attachment%3B%20filename%3D%22test.js%22%3B%20filename%2A%3DUTF-8%27%27test.js&response-content-type=application/ecmascript)
expect(Gitlab::Workhorse).to receive(:send_url).with(expected_headers).and_call_original
expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-url:/)
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
index 59463462e5a..232a5e2793b 100644
--- a/spec/controllers/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -45,6 +45,29 @@ describe OmniauthCallbacksController, type: :controller do
end
end
+ context 'when sign in fails' do
+ include RoutesHelpers
+
+ let(:extern_uid) { 'my-uid' }
+ let(:provider) { :saml }
+
+ def stub_route_as(path)
+ allow(@routes).to receive(:generate_extras) { [path, []] }
+ end
+
+ it 'it calls through to the failure handler' do
+ request.env['omniauth.error'] = OneLogin::RubySaml::ValidationError.new("Fingerprint mismatch")
+ request.env['omniauth.error.strategy'] = OmniAuth::Strategies::SAML.new(nil)
+ stub_route_as('/users/auth/saml/callback')
+
+ ForgeryProtection.with_forgery_protection do
+ post :failure
+ end
+
+ expect(flash[:alert]).to match(/Fingerprint mismatch/)
+ end
+ end
+
context 'when a redirect fragment is provided' do
let(:provider) { :jwt }
let(:extern_uid) { 'my-uid' }
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index bd10de45b67..29df00e6bb0 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -26,8 +26,15 @@ describe Projects::ArtifactsController do
end
context 'when no file type is supplied' do
+ let(:filename) { job.artifacts_file.filename }
+
it 'sends the artifacts file' do
- expect(controller).to receive(:send_file).with(job.artifacts_file.path, hash_including(disposition: 'attachment')).and_call_original
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ expect(controller).to receive(:send_file)
+ .with(
+ job.artifacts_file.file.path,
+ hash_including(disposition: %Q(attachment; filename*=UTF-8''#{filename}))).and_call_original
download_artifact
end
@@ -46,6 +53,7 @@ describe Projects::ArtifactsController do
context 'when codequality file type is supplied' do
let(:file_type) { 'codequality' }
+ let(:filename) { job.job_artifacts_codequality.filename }
context 'when file is stored locally' do
before do
@@ -53,7 +61,11 @@ describe Projects::ArtifactsController do
end
it 'sends the codequality report' do
- expect(controller).to receive(:send_file).with(job.job_artifacts_codequality.file.path, hash_including(disposition: 'attachment')).and_call_original
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ expect(controller).to receive(:send_file)
+ .with(job.job_artifacts_codequality.file.path,
+ hash_including(disposition: %Q(attachment; filename*=UTF-8''#{filename}))).and_call_original
download_artifact(file_type: file_type)
end
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index a4d494a820f..aa97a417a98 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -422,6 +422,79 @@ describe Projects::EnvironmentsController do
end
end
+ describe 'GET #search' do
+ before do
+ create(:environment, name: 'staging', project: project)
+ create(:environment, name: 'review/patch-1', project: project)
+ create(:environment, name: 'review/patch-2', project: project)
+ end
+
+ let(:query) { 'pro' }
+
+ it 'responds with status code 200' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response).to contain_exactly('production')
+ end
+
+ context 'when query is review' do
+ let(:query) { 'review' }
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response).to contain_exactly('review/patch-1', 'review/patch-2')
+ end
+ end
+
+ context 'when query is empty' do
+ let(:query) { '' }
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response)
+ .to contain_exactly('production', 'staging', 'review/patch-1', 'review/patch-2')
+ end
+ end
+
+ context 'when query is review/patch-3' do
+ let(:query) { 'review/patch-3' }
+
+ it 'responds with status code 204' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when query is partially matched in the middle of environment name' do
+ let(:query) { 'patch' }
+
+ it 'responds with status code 204' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when query contains a wildcard character' do
+ let(:query) { 'review%' }
+
+ it 'prevents wildcard injection' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 4743ad04339..c34d7c13d57 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -379,6 +379,23 @@ describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(200)
end
end
+
+ context 'when getting the changes' do
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it 'returns the necessary data' do
+ go(id: issue.iid)
+
+ data = JSON.parse(response.body)
+
+ expect(data).to include('title_text', 'description', 'description_text')
+ expect(data).to include('task_status', 'lock_version')
+ end
+ end
end
describe 'Confidential Issues' do
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 4a5d2bdecb7..601a292bf54 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -152,6 +152,16 @@ describe Projects::ServicesController do
expect(service.namespace).not_to eq('updated_namespace')
end
end
+
+ context 'when activating JIRA service from a template' do
+ let(:template_service) { create(:jira_service, project: project, template: true) }
+
+ it 'activate JIRA service from template' do
+ put :update, params: { namespace_id: project.namespace, project_id: project, id: service.to_param, service: { active: true } }
+
+ expect(flash[:notice]).to eq 'JIRA activated.'
+ end
+ end
end
describe "GET #edit" do
diff --git a/spec/factories/project_error_tracking_settings.rb b/spec/factories/project_error_tracking_settings.rb
index fbd8dfd395c..be30bd0260a 100644
--- a/spec/factories/project_error_tracking_settings.rb
+++ b/spec/factories/project_error_tracking_settings.rb
@@ -6,5 +6,7 @@ FactoryBot.define do
api_url 'https://gitlab.com/api/0/projects/sentry-org/sentry-project'
enabled true
token 'access_token_123'
+ project_name 'Sentry Project'
+ organization_name 'Sentry Org'
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 1906c06a211..18fab395cc2 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -1,7 +1,7 @@
require_relative '../support/helpers/test_env'
FactoryBot.define do
- PAGES_ACCESS_LEVEL_SCHEMA_VERSION = 20180423204600
+ PAGES_ACCESS_LEVEL_SCHEMA_VERSION ||= 20180423204600
# Project without repository
#
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb
index 3c2ae5b3c6a..57215c0d1e9 100644
--- a/spec/features/admin/admin_appearance_spec.rb
+++ b/spec/features/admin/admin_appearance_spec.rb
@@ -10,10 +10,10 @@ describe 'Admin Appearance' do
fill_in 'appearance_title', with: 'MyCompany'
fill_in 'appearance_description', with: 'dev server'
fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines'
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(current_path).to eq admin_appearances_path
- expect(page).to have_content 'Appearance settings'
+ expect(page).to have_content 'Appearance'
expect(page).to have_field('appearance_title', with: 'MyCompany')
expect(page).to have_field('appearance_description', with: 'dev server')
@@ -57,7 +57,7 @@ describe 'Admin Appearance' do
visit admin_appearances_path
attach_file(:appearance_logo, logo_fixture)
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_css(logo_selector)
click_link 'Remove logo'
@@ -69,7 +69,7 @@ describe 'Admin Appearance' do
visit admin_appearances_path
attach_file(:appearance_header_logo, logo_fixture)
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_css(header_logo_selector)
click_link 'Remove header logo'
@@ -81,7 +81,7 @@ describe 'Admin Appearance' do
visit admin_appearances_path
attach_file(:appearance_favicon, logo_fixture)
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_css('.appearance-light-logo-preview')
@@ -91,7 +91,7 @@ describe 'Admin Appearance' do
# allowed file types
attach_file(:appearance_favicon, Rails.root.join('spec', 'fixtures', 'sanitized.svg'))
- click_button 'Save'
+ click_button 'Update appearance settings'
expect(page).to have_content 'Favicon You are not allowed to upload "svg" files, allowed types: png, ico'
end
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
index 3b37ede8579..8815643ca96 100644
--- a/spec/features/markdown/markdown_spec.rb
+++ b/spec/features/markdown/markdown_spec.rb
@@ -13,7 +13,7 @@ require 'erb'
#
# Raw Markdown
# -> `markdown` helper
-# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML
+# -> CommonMark::Render::GitlabHTML converts Markdown to HTML
# -> Post-process HTML
# -> `gfm` helper
# -> HTML::Pipeline
@@ -324,31 +324,6 @@ describe 'GitLab Markdown', :aggregate_failures do
end
end
- context 'Redcarpet documents' do
- before do
- allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
- @html = markdown(@feat.raw_markdown)
- end
-
- it 'processes certain elements differently' do
- aggregate_failures 'parses superscript' do
- expect(doc).to have_selector('sup', count: 3)
- end
-
- aggregate_failures 'permits style attribute in th elements' do
- expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center'
- expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right'
- expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left'
- end
-
- aggregate_failures 'permits style attribute in td elements' do
- expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center'
- expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right'
- expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left'
- end
- end
- end
-
# Fake a `current_user` helper
def current_user
@feat.user
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index ee5f5377ca6..1bbcf455ac7 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -66,6 +66,38 @@ describe 'Merge request > User posts notes', :js do
is_expected.to have_css('.js-note-text', visible: true)
end
end
+
+ describe 'when reply_to_individual_notes feature flag is not set' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not show a reply button' do
+ expect(page).to have_no_selector('.js-reply-button')
+ end
+ end
+
+ describe 'when reply_to_individual_notes feature flag is set' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows a reply button' do
+ reply_button = find('.js-reply-button', match: :first)
+
+ expect(reply_button).to have_selector('.ic-comment')
+ end
+
+ it 'shows reply placeholder when clicking reply button' do
+ reply_button = find('.js-reply-button', match: :first)
+
+ reply_button.click
+
+ expect(page).to have_selector('.discussion-reply-holder')
+ end
+ end
end
describe 'when previewing a note' do
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index f45bcabd196..b43711f6ef6 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -10,6 +10,7 @@ describe 'User edit profile' do
def submit_settings
click_button 'Update profile settings'
+ wait_for_requests if respond_to?(:wait_for_requests)
end
it 'changes user profile' do
@@ -35,6 +36,17 @@ describe 'User edit profile' do
expect(page).to have_content('Profile was successfully updated')
end
+ it 'shows an error if the full name contains an emoji', :js do
+ simulate_input('#user_name', 'Martin 😀')
+ submit_settings
+
+ page.within('.qa-full-name') do
+ expect(page).to have_css '.gl-field-error-outline'
+ expect(find('.gl-field-error')).not_to have_selector('.hidden')
+ expect(find('.gl-field-error')).to have_content('Using emojis in names seems fun, but please try to set a status message instead')
+ end
+ end
+
context 'user avatar' do
before do
attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif'))
@@ -61,6 +73,11 @@ describe 'User edit profile' do
end
context 'user status', :js do
+ def visit_user
+ visit user_path(user)
+ wait_for_requests
+ end
+
def select_emoji(emoji_name, is_modal = false)
emoji_menu_class = is_modal ? '.js-modal-status-emoji-menu' : '.js-status-emoji-menu'
toggle_button = find('.js-toggle-emoji-menu')
@@ -71,18 +88,16 @@ describe 'User edit profile' do
context 'profile edit form' do
it 'shows the user status form' do
- visit(profile_path)
-
expect(page).to have_content('Current status')
end
it 'adds emoji to user status' do
emoji = 'biohazard'
- visit(profile_path)
select_emoji(emoji)
submit_settings
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
end
@@ -90,11 +105,11 @@ describe 'User edit profile' do
it 'adds message to user status' do
message = 'I have something to say'
- visit(profile_path)
fill_in 'js-status-message-field', with: message
submit_settings
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji('speech_balloon')
expect(page).to have_content message
@@ -104,12 +119,12 @@ describe 'User edit profile' do
it 'adds message and emoji to user status' do
emoji = 'tanabata_tree'
message = 'Playing outside'
- visit(profile_path)
select_emoji(emoji)
fill_in 'js-status-message-field', with: message
submit_settings
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
expect(page).to have_content message
@@ -119,7 +134,8 @@ describe 'User edit profile' do
it 'clears the user status' do
user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
@@ -129,15 +145,13 @@ describe 'User edit profile' do
click_button 'js-clear-user-status-button'
submit_settings
- wait_for_requests
+ visit_user
- visit user_path(user)
expect(page).not_to have_selector '.cover-status'
end
it 'displays a default emoji if only message is entered' do
message = 'a status without emoji'
- visit(profile_path)
fill_in 'js-status-message-field', with: message
within('.js-toggle-emoji-menu') do
@@ -162,6 +176,7 @@ describe 'User edit profile' do
page.within "#set-user-status-modal" do
click_button 'Set status'
end
+ wait_for_requests
end
before do
@@ -202,7 +217,8 @@ describe 'User edit profile' do
select_emoji(emoji, true)
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
end
@@ -225,7 +241,8 @@ describe 'User edit profile' do
find('.js-status-message-field').native.send_keys(message)
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji('speech_balloon')
expect(page).to have_content message
@@ -240,7 +257,8 @@ describe 'User edit profile' do
find('.js-status-message-field').native.send_keys(message)
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+
within('.cover-status') do
expect(page).to have_emoji(emoji)
expect(page).to have_content message
@@ -250,7 +268,9 @@ describe 'User edit profile' do
it 'clears the user status with the "X" button' do
user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
- visit user_path(user)
+ visit_user
+ wait_for_requests
+
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
@@ -265,14 +285,18 @@ describe 'User edit profile' do
find('.js-clear-user-status-button').click
set_user_status_in_modal
- visit user_path(user)
+ visit_user
+ wait_for_requests
+
expect(page).not_to have_selector '.cover-status'
end
it 'clears the user status with the "Remove status" button' do
user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
- visit user_path(user)
+ visit_user
+ wait_for_requests
+
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
@@ -288,7 +312,8 @@ describe 'User edit profile' do
click_button 'Remove status'
end
- visit user_path(user)
+ visit_user
+
expect(page).not_to have_selector '.cover-status'
end
diff --git a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb
index 554f0b49052..5cb015e80be 100644
--- a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb
+++ b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb
@@ -7,7 +7,7 @@ describe "User downloads artifacts" do
shared_examples "downloading" do
it "downloads the zip" do
- expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
+ expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename*=UTF-8''#{job.artifacts_file.filename}; filename="#{job.artifacts_file.filename}"})
expect(page.response_headers['Content-Transfer-Encoding']).to eq("binary")
expect(page.response_headers['Content-Type']).to eq("application/zip")
expect(page.source.b).to eq(job.artifacts_file.file.read.b)
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index e2f9e7e9cc5..3edcc7ac2cd 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -5,8 +5,8 @@ describe 'File blob', :js do
let(:project) { create(:project, :public, :repository) }
- def visit_blob(path, anchor: nil, ref: 'master', legacy_render: nil)
- visit project_blob_path(project, File.join(ref, path), anchor: anchor, legacy_render: legacy_render)
+ def visit_blob(path, anchor: nil, ref: 'master')
+ visit project_blob_path(project, File.join(ref, path), anchor: anchor)
wait_for_requests
end
@@ -171,21 +171,6 @@ describe 'File blob', :js do
end
end
end
-
- context 'when rendering legacy markdown' do
- before do
- visit_blob('files/commonmark/file.md', legacy_render: 1)
-
- wait_for_requests
- end
-
- it 'renders using RedCarpet' do
- aggregate_failures do
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
- end
- end
end
context 'Markdown file (stored in LFS)' do
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index d5b20605860..6e6c299ee2e 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -81,17 +81,6 @@ describe 'Editing file blob', :js do
expect(page).to have_content("sublist")
expect(page).not_to have_xpath("//ol//li//ul")
end
-
- it 'renders content with RedCarpet when legacy_render is set' do
- visit project_edit_blob_path(project, tree_join(branch, readme_file_path), legacy_render: 1)
- fill_editor(content: "1. one\\n - sublist\\n")
- click_link 'Preview'
- wait_for_requests
-
- # the above generates a sublist list in RedCarpet
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
end
end
diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb
index fab9e035d53..2c8d014c36d 100644
--- a/spec/features/projects/clusters/applications_spec.rb
+++ b/spec/features/projects/clusters/applications_spec.rb
@@ -48,9 +48,9 @@ describe 'Clusters Applications', :js do
it 'they see status transition' do
page.within('.js-cluster-application-row-helm') do
- # FE sends request and gets the response, then the buttons is "Install"
+ # FE sends request and gets the response, then the buttons is "Installing"
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
wait_until_helm_created!
@@ -118,7 +118,7 @@ describe 'Clusters Applications', :js do
page.within('.js-cluster-application-row-cert_manager') do
expect(email_form_value).to eq(cluster.user.email)
- expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
page.find('.js-email').set("new_email@example.org")
Clusters::Cluster.last.application_cert_manager.make_installing!
@@ -153,9 +153,9 @@ describe 'Clusters Applications', :js do
it 'they see status transition' do
page.within('.js-cluster-application-row-ingress') do
- # FE sends request and gets the response, then the buttons is "Install"
+ # FE sends request and gets the response, then the buttons is "Installing"
expect(page).to have_css('.js-cluster-application-install-button[disabled]')
- expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
Clusters::Cluster.last.application_ingress.make_installing!
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 24830b2bd3e..65ce872363f 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -220,7 +220,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
artifact_request = requests.find { |req| req.url.match(%r{artifacts/download}) }
- expect(artifact_request.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
+ expect(artifact_request.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename*=UTF-8''#{job.artifacts_file.filename}; filename="#{job.artifacts_file.filename}"})
expect(artifact_request.response_headers['Content-Transfer-Encoding']).to eq("binary")
expect(artifact_request.response_headers['Content-Type']).to eq("image/gif")
expect(artifact_request.body).to eq(job.artifacts_file.file.read.b)
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
index 766c63725b3..aa71669de98 100644
--- a/spec/features/projects/serverless/functions_spec.rb
+++ b/spec/features/projects/serverless/functions_spec.rb
@@ -1,6 +1,10 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Functions', :js do
+ include KubernetesHelpers
+
let(:project) { create(:project) }
let(:user) { create(:user) }
@@ -34,11 +38,14 @@ describe 'Functions', :js do
end
context 'when the user has a cluster and knative installed and visits the serverless page' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:service) { cluster.platform_kubernetes }
let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:project) { knative.cluster.project }
before do
+ stub_kubeclient_knative_services
+ stub_kubeclient_service_pods
visit project_serverless_functions_path(project)
end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index f505023d1d0..3b469fee867 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -175,20 +175,6 @@ describe 'Projects > Wiki > User previews markdown changes', :js do
expect(page).to have_content("sublist")
expect(page).not_to have_xpath("//ol//li//ul")
end
-
- it 'renders content with RedCarpet when legacy_render is set' do
- wiki_page = create(:wiki_page,
- wiki: project.wiki,
- attrs: { title: 'home', content: "Empty content" })
- visit(project_wiki_edit_path(project, wiki_page, legacy_render: 1))
-
- fill_in :wiki_content, with: "1. one\n - sublist\n"
- click_on "Preview"
-
- # the above generates a sublist list in RedCarpet
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index f7efc3f325c..bc36c6f948f 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -110,16 +110,23 @@ describe 'Project' do
it 'shows project topics' do
project.update_attribute(:tag_list, 'topic1')
+
visit path
+
expect(page).to have_css('.home-panel-topic-list')
- expect(page).to have_content('topic1')
+ expect(page).to have_link('Topic1', href: explore_projects_path(tag: 'topic1'))
end
it 'shows up to 3 project tags' do
project.update_attribute(:tag_list, 'topic1, topic2, topic3, topic4')
+
visit path
+
expect(page).to have_css('.home-panel-topic-list')
- expect(page).to have_content('topic1, topic2, topic3 + 1 more')
+ expect(page).to have_link('Topic1', href: explore_projects_path(tag: 'topic1'))
+ expect(page).to have_link('Topic2', href: explore_projects_path(tag: 'topic2'))
+ expect(page).to have_link('Topic3', href: explore_projects_path(tag: 'topic3'))
+ expect(page).to have_content('+ 1 more')
end
end
diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb
index 367a479f62a..eb974c7c7fd 100644
--- a/spec/features/snippets/show_spec.rb
+++ b/spec/features/snippets/show_spec.rb
@@ -80,19 +80,6 @@ describe 'Snippet', :js do
end
end
- context 'when rendering legacy markdown' do
- before do
- visit snippet_path(snippet, legacy_render: 1)
-
- wait_for_requests
- end
-
- it 'renders using RedCarpet' do
- expect(page).to have_content("sublist")
- expect(page).to have_xpath("//ol//li//ul")
- end
- end
-
context 'with cached CommonMark html' do
let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
@@ -100,14 +87,6 @@ describe 'Snippet', :js do
expect(page).not_to have_xpath("//ol//li//ul")
end
end
-
- context 'with cached Redcarpet html' do
- let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION) }
-
- it 'renders correctly' do
- expect(page).to have_xpath("//ol//li//ul")
- end
- end
end
context 'switching to the simple viewer' do
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index 9c9127980a1..6fe840dccf6 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -36,19 +36,6 @@ describe 'Task Lists' do
MARKDOWN
end
- let(:nested_tasks_markdown_redcarpet) do
- <<-EOT.strip_heredoc
- - [ ] Task a
- - [x] Task a.1
- - [ ] Task a.2
- - [ ] Task b
-
- 1. [ ] Task 1
- 1. [ ] Task 1.1
- 1. [x] Task 1.2
- EOT
- end
-
let(:nested_tasks_markdown) do
<<-EOT.strip_heredoc
- [ ] Task a
@@ -153,59 +140,6 @@ describe 'Task Lists' do
expect(page).to have_content("1 of 1 task completed")
end
end
-
- shared_examples 'shared nested tasks' do
- before do
- allow(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
- visit_issue(project, issue)
- end
- it 'renders' do
- expect(page).to have_selector('ul.task-list', count: 2)
- expect(page).to have_selector('li.task-list-item', count: 7)
- expect(page).to have_selector('ul input[checked]', count: 1)
- expect(page).to have_selector('ol input[checked]', count: 1)
- end
-
- it 'solves tasks' do
- expect(page).to have_content("2 of 7 tasks completed")
-
- page.find('li.task-list-item', text: 'Task b').find('input').click
- page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click
- page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click
-
- expect(page).to have_content("5 of 7 tasks completed")
-
- visit_issue(project, issue) # reload to see new system notes
-
- expect(page).to have_content('marked the task Task b as complete')
- expect(page).to have_content('marked the task Task a.2 as complete')
- expect(page).to have_content('marked the task Task 1.1 as complete')
- end
- end
-
- describe 'nested tasks', :js do
- context 'with Redcarpet' do
- let(:issue) { create(:issue, description: nested_tasks_markdown_redcarpet, author: user, project: project) }
-
- before do
- allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
- visit_issue(project, issue)
- end
-
- it_behaves_like 'shared nested tasks'
- end
-
- context 'with CommonMark' do
- let(:issue) { create(:issue, description: nested_tasks_markdown, author: user, project: project) }
-
- before do
- allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('CommonMark')
- visit_issue(project, issue)
- end
-
- it_behaves_like 'shared nested tasks'
- end
- end
end
describe 'for Notes' do
diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb
index 3708f0ee477..3db9ae7a951 100644
--- a/spec/features/users/overview_spec.rb
+++ b/spec/features/users/overview_spec.rb
@@ -34,7 +34,7 @@ describe 'Overview tab on a user profile', :js do
it 'does not show any entries in the list of activities' do
page.within('.activities-block') do
expect(page).to have_selector('.loading', visible: false)
- expect(page).to have_content('No activities found')
+ expect(page).to have_content('Join or create a group to start contributing by commenting on issues or submitting merge requests!')
expect(page).not_to have_selector('.event-item')
end
end
@@ -96,7 +96,7 @@ describe 'Overview tab on a user profile', :js do
it 'it shows an empty project list with an info message' do
page.within('.projects-block') do
expect(page).to have_selector('.loading', visible: false)
- expect(page).to have_content('This user doesn\'t have any personal projects')
+ expect(page).to have_content('You haven\'t created any personal projects.')
expect(page).not_to have_selector('.project-row')
end
end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 682fae06434..34cb09942be 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -314,6 +314,14 @@ describe IssuesFinder do
end
end
+ context 'filtering by issue term in title' do
+ let(:params) { { search: 'git', in: 'title' } }
+
+ it 'returns issues with title match for search term' do
+ expect(issues).to contain_exactly(issue1)
+ end
+ end
+
context 'filtering by issues iids' do
let(:params) { { iids: issue3.iid } }
diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json
index 4c04c838cb8..3006b482d41 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_basic.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json
@@ -22,7 +22,8 @@
"type": [ "array", "null" ]
},
"task_status": { "type": "string" },
- "task_status_short": { "type": "string" }
+ "task_status_short": { "type": "string" },
+ "lock_version": { "type": ["string", "null"] }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index e5d01c3bd03..bbeacf1707b 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -6,7 +6,7 @@ started.
## Markdown
-GitLab uses [Redcarpet](http://git.io/ld_NVQ) to parse all Markdown into
+GitLab uses [Commonmark](https://git.io/fhDag) to parse all Markdown into
HTML.
It has some special features. Let's try 'em out!
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index ca90673521c..1a54ab540fc 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -32,6 +32,26 @@ describe Resolvers::IssuesResolver do
expect(resolve_issues).to contain_exactly(issue, issue2)
end
+
+ it 'finds a specific issue with iids' do
+ expect(resolve_issues(iids: issue.iid)).to contain_exactly(issue)
+ end
+
+ it 'finds multiple issues with iids' do
+ expect(resolve_issues(iids: [issue.iid, issue2.iid]))
+ .to contain_exactly(issue, issue2)
+ end
+
+ it 'finds only the issues within the project we are looking at' do
+ another_project = create(:project)
+ iids = [issue, issue2].map(&:iid)
+
+ iids.each do |iid|
+ create(:issue, project: another_project, iid: iid)
+ end
+
+ expect(resolve_issues(iids: iids)).to contain_exactly(issue, issue2)
+ end
end
def resolve_issues(args = {}, context = { current_user: current_user })
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 03e3a72a82f..8b82dea2524 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -188,8 +188,8 @@ describe IssuablesHelper do
issuableRef: "##{issue.iid}",
markdownPreviewPath: "/#{@project.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown',
- markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION,
issuableTemplates: [],
+ lockVersion: issue.lock_version,
projectPath: @project.path,
projectNamespace: @project.namespace.path,
initialTitleHtml: issue.title,
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index a0c0af94fa5..c3956ba08fd 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -212,17 +212,6 @@ describe MarkupHelper do
helper.render_wiki_content(@wiki)
end
- it 'uses Wiki pipeline for markdown files with RedCarpet if feature disabled' do
- stub_feature_flags(commonmark_for_repositories: false)
- allow(@wiki).to receive(:format).and_return(:markdown)
-
- expect(helper).to receive(:markdown_unsafe).with('wiki content',
- pipeline: :wiki, project: project, project_wiki: @wiki, page_slug: "nested/page",
- issuable_state_filter_enabled: true, markdown_engine: :redcarpet)
-
- helper.render_wiki_content(@wiki)
- end
-
it "uses Asciidoctor for asciidoc files" do
allow(@wiki).to receive(:format).and_return(:asciidoc)
@@ -273,16 +262,6 @@ describe MarkupHelper do
it 'defaults to CommonMark' do
expect(helper.markup('foo.md', 'x^2')).to include('x^2')
end
-
- it 'honors markdown_engine for RedCarpet' do
- expect(helper.markup('foo.md', 'x^2', { markdown_engine: :redcarpet })).to include('x<sup>2</sup>')
- end
-
- it 'uses RedCarpet if feature disabled' do
- stub_feature_flags(commonmark_for_repositories: false)
-
- expect(helper.markup('foo.md', 'x^2', { markdown_engine: :redcarpet })).to include('x<sup>2</sup>')
- end
end
describe '#first_line_in_markdown' do
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 10f61731206..49895b0680b 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -372,21 +372,16 @@ describe ProjectsHelper do
end
context 'when project has external wiki' do
- before do
- allow(project).to receive(:has_external_wiki?).and_return(true)
- end
-
it 'includes external wiki tab' do
+ project.create_external_wiki_service(active: true, properties: { 'external_wiki_url' => 'https://gitlab.com' })
+
is_expected.to include(:external_wiki)
end
end
context 'when project does not have external wiki' do
- before do
- allow(project).to receive(:has_external_wiki?).and_return(false)
- end
-
it 'does not include external wiki tab' do
+ expect(project.external_wiki).to be_nil
is_expected.not_to include(:external_wiki)
end
end
@@ -540,18 +535,6 @@ describe ProjectsHelper do
end
end
- describe '#legacy_render_context' do
- it 'returns the redcarpet engine' do
- params = { legacy_render: '1' }
-
- expect(helper.legacy_render_context(params)).to include(markdown_engine: :redcarpet)
- end
-
- it 'returns nothing' do
- expect(helper.legacy_render_context({})).to be_empty
- end
- end
-
describe '#explore_projects_tab?' do
subject { helper.explore_projects_tab? }
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index 34d9115a1f6..ab67a5ab847 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -51,7 +51,7 @@ describe UsersHelper do
false | 'mockRegexPattern' | { user_internal_regex_pattern: nil, user_internal_regex_options: nil }
true | nil | { user_internal_regex_pattern: nil, user_internal_regex_options: nil }
true | '' | { user_internal_regex_pattern: nil, user_internal_regex_options: nil }
- true | 'mockRegexPattern' | { user_internal_regex_pattern: 'mockRegexPattern', user_internal_regex_options: 'gi' }
+ true | 'mockRegexPattern' | { user_internal_regex_pattern: 'mockRegexPattern', user_internal_regex_options: 'i' }
end
with_them do
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
index 9d55c615450..1e9470970ff 100644
--- a/spec/javascripts/api_spec.js
+++ b/spec/javascripts/api_spec.js
@@ -49,6 +49,22 @@ describe('Api', () => {
});
});
+ describe('groupMembers', () => {
+ it('fetches group members', done => {
+ const groupId = '54321';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`;
+ const expectedData = [{ id: 7 }];
+ mock.onGet(expectedUrl).reply(200, expectedData);
+
+ Api.groupMembers(groupId)
+ .then(({ data }) => {
+ expect(data).toEqual(expectedData);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
describe('groups', () => {
it('fetches groups', done => {
const query = 'dummy query';
diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js
index 880b469284b..7928feeadfa 100644
--- a/spec/javascripts/clusters/clusters_bundle_spec.js
+++ b/spec/javascripts/clusters/clusters_bundle_spec.js
@@ -1,10 +1,5 @@
import Clusters from '~/clusters/clusters_bundle';
-import {
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
- APPLICATION_STATUS,
-} from '~/clusters/constants';
+import { REQUEST_SUBMITTED, REQUEST_FAILURE, APPLICATION_STATUS } from '~/clusters/constants';
import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
describe('Clusters', () => {
@@ -196,67 +191,43 @@ describe('Clusters', () => {
});
describe('installApplication', () => {
- it('tries to install helm', done => {
+ it('tries to install helm', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
cluster.installApplication({ id: 'helm' });
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
- it('tries to install ingress', done => {
+ it('tries to install ingress', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null);
cluster.installApplication({ id: 'ingress' });
- expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
- it('tries to install runner', done => {
+ it('tries to install runner', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.runner.requestStatus).toEqual(null);
cluster.installApplication({ id: 'runner' });
- expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
- it('tries to install jupyter', done => {
+ it('tries to install jupyter', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null);
@@ -265,19 +236,11 @@ describe('Clusters', () => {
params: { hostname: cluster.store.state.applications.jupyter.hostname },
});
- expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', {
hostname: cluster.store.state.applications.jupyter.hostname,
});
-
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUCCESS);
- expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
- })
- .then(done)
- .catch(done.fail);
});
it('sets error request status when the request fails', done => {
@@ -289,7 +252,7 @@ describe('Clusters', () => {
cluster.installApplication({ id: 'helm' });
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING);
+ expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalled();
diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js
index 45d56514930..d1f4a1cebb4 100644
--- a/spec/javascripts/clusters/components/application_row_spec.js
+++ b/spec/javascripts/clusters/components/application_row_spec.js
@@ -1,11 +1,6 @@
import Vue from 'vue';
import eventHub from '~/clusters/event_hub';
-import {
- APPLICATION_STATUS,
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
-} from '~/clusters/constants';
+import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
@@ -57,6 +52,12 @@ describe('Application Row', () => {
expect(vm.installButtonLabel).toBeUndefined();
});
+ it('has install button', () => {
+ const installationBtn = vm.$el.querySelector('.js-cluster-application-install-button');
+
+ expect(installationBtn).not.toBe(null);
+ });
+
it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@@ -101,6 +102,18 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
+ it('has loading "Installing" when REQUEST_SUBMITTED', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.INSTALLABLE,
+ requestStatus: REQUEST_SUBMITTED,
+ });
+
+ expect(vm.installButtonLabel).toEqual('Installing');
+ expect(vm.installButtonLoading).toEqual(true);
+ expect(vm.installButtonDisabled).toEqual(true);
+ });
+
it('has disabled "Installed" when APPLICATION_STATUS.INSTALLED', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@@ -134,30 +147,6 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(false);
});
- it('has loading "Install" when REQUEST_LOADING', () => {
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_STATUS.INSTALLABLE,
- requestStatus: REQUEST_LOADING,
- });
-
- expect(vm.installButtonLabel).toEqual('Install');
- expect(vm.installButtonLoading).toEqual(true);
- expect(vm.installButtonDisabled).toEqual(true);
- });
-
- it('has disabled "Install" when REQUEST_SUCCESS', () => {
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_STATUS.INSTALLABLE,
- requestStatus: REQUEST_SUCCESS,
- });
-
- expect(vm.installButtonLabel).toEqual('Install');
- expect(vm.installButtonLoading).toEqual(false);
- expect(vm.installButtonDisabled).toEqual(true);
- });
-
it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
diff --git a/spec/javascripts/diffs/components/tree_list_spec.js b/spec/javascripts/diffs/components/tree_list_spec.js
index 08b0b4f9e45..c5ef48a81e9 100644
--- a/spec/javascripts/diffs/components/tree_list_spec.js
+++ b/spec/javascripts/diffs/components/tree_list_spec.js
@@ -83,17 +83,6 @@ describe('Diffs tree list component', () => {
expect(vm.$el.querySelectorAll('.file-row')[1].textContent).toContain('app');
});
- it('filters tree list to blobs matching search', done => {
- vm.search = 'app/index';
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelectorAll('.file-row').length).toBe(1);
- expect(vm.$el.querySelectorAll('.file-row')[0].textContent).toContain('index.js');
-
- done();
- });
- });
-
it('calls toggleTreeOpen when clicking folder', () => {
spyOn(vm.$store, 'dispatch').and.stub();
@@ -130,14 +119,4 @@ describe('Diffs tree list component', () => {
});
});
});
-
- describe('clearSearch', () => {
- it('resets search', () => {
- vm.search = 'test';
-
- vm.$el.querySelector('.tree-list-clear-icon').click();
-
- expect(vm.search).toBe('');
- });
- });
});
diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js
index 190ca1230ca..4f69dc92ab8 100644
--- a/spec/javascripts/diffs/store/getters_spec.js
+++ b/spec/javascripts/diffs/store/getters_spec.js
@@ -242,7 +242,11 @@ describe('Diffs Module Getters', () => {
},
};
- expect(getters.allBlobs(localState)).toEqual([
+ expect(
+ getters.allBlobs(localState, {
+ flatBlobsList: getters.flatBlobsList(localState),
+ }),
+ ).toEqual([
{
isHeader: true,
path: '/',
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
index 55f40be0e4e..dc5790f6562 100644
--- a/spec/javascripts/ide/components/ide_spec.js
+++ b/spec/javascripts/ide/components/ide_spec.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import Mousetrap from 'mousetrap';
import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
@@ -72,73 +71,6 @@ describe('ide component', () => {
});
});
- describe('file finder', () => {
- beforeEach(done => {
- spyOn(vm, 'toggleFileFinder');
-
- vm.$store.state.fileFindVisible = true;
-
- vm.$nextTick(done);
- });
-
- it('calls toggleFileFinder on `t` key press', done => {
- Mousetrap.trigger('t');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('calls toggleFileFinder on `command+p` key press', done => {
- Mousetrap.trigger('command+p');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('calls toggleFileFinder on `ctrl+p` key press', done => {
- Mousetrap.trigger('ctrl+p');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('always allows `command+p` to trigger toggleFileFinder', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
- ).toBe(false);
- });
-
- it('always allows `ctrl+p` to trigger toggleFileFinder', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
- ).toBe(false);
- });
-
- it('onlys handles `t` when focused in input-field', () => {
- expect(
- vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
- ).toBe(true);
- });
-
- it('stops callback in monaco editor', () => {
- setFixtures('<div class="inputarea"></div>');
-
- expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true);
- });
- });
-
it('shows error message when set', done => {
expect(vm.$el.querySelector('.flash-container')).toBe(null);
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 2bd1b3996dc..0ccf771c7ef 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -18,9 +18,13 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0;
let vm;
- document.body.innerHTML = '<span id="task_status"></span>';
-
beforeEach(done => {
+ setFixtures(`
+ <div>
+ <div class="flash-container"></div>
+ <span id="task_status"></span>
+ </div>
+ `);
spyOn(eventHub, '$emit');
const IssuableDescriptionComponent = Vue.extend(issuableApp);
@@ -43,6 +47,7 @@ describe('Issuable output', () => {
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
+ lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
@@ -78,6 +83,7 @@ describe('Issuable output', () => {
expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(1);
})
.then(() => {
vm.poll.makeRequest();
@@ -95,6 +101,7 @@ describe('Issuable output', () => {
expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(2);
})
.then(done)
.catch(done.fail);
@@ -137,21 +144,17 @@ describe('Issuable output', () => {
describe('updateIssuable', () => {
it('fetches new data after update', done => {
+ spyOn(vm, 'updateStoreState').and.callThrough();
spyOn(vm.service, 'getData').and.callThrough();
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- confidential: false,
- web_url: window.location.pathname,
- },
- });
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: { web_url: window.location.pathname },
+ }),
);
vm.updateIssuable()
.then(() => {
+ expect(vm.updateStoreState).toHaveBeenCalled();
expect(vm.service.getData).toHaveBeenCalled();
})
.then(done)
@@ -159,11 +162,10 @@ describe('Issuable output', () => {
});
it('correctly updates issuable data', done => {
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve();
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: { web_url: window.location.pathname },
+ }),
);
vm.updateIssuable()
@@ -177,16 +179,13 @@ describe('Issuable output', () => {
it('does not redirect if issue has not moved', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: window.location.pathname,
- confidential: vm.isConfidential,
- },
- });
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: window.location.pathname,
+ confidential: vm.isConfidential,
+ },
+ }),
);
vm.updateIssuable();
@@ -199,16 +198,13 @@ describe('Issuable output', () => {
it('redirects if returned web_url has changed', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: '/testing-issue-move',
- confidential: vm.isConfidential,
- },
- });
- }),
+ spyOn(vm.service, 'updateIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: '/testing-issue-move',
+ confidential: vm.isConfidential,
+ },
+ }),
);
vm.updateIssuable();
@@ -227,6 +223,7 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => {
expect(e.returnValue).not.toBeNull();
+
done();
});
});
@@ -238,6 +235,7 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => {
expect(e.returnValue).not.toBeNull();
+
done();
});
});
@@ -247,49 +245,61 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => {
expect(e.returnValue).toBeNull();
+
done();
});
});
});
describe('error when updating', () => {
- beforeEach(() => {
- spyOn(window, 'Flash').and.callThrough();
- spyOn(vm.service, 'updateIssuable').and.callFake(
- () =>
- new Promise((resolve, reject) => {
- reject();
- }),
- );
- });
-
it('closes form on error', done => {
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
vm.updateIssuable();
setTimeout(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
-
- expect(window.Flash).toHaveBeenCalledWith('Error updating issue');
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating issue`,
+ );
done();
});
});
it('returns the correct error message for issuableType', done => {
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
vm.issuableType = 'merge request';
Vue.nextTick(() => {
vm.updateIssuable();
setTimeout(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
-
- expect(window.Flash).toHaveBeenCalledWith('Error updating merge request');
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating merge request`,
+ );
done();
});
});
});
+
+ it('shows error mesage from backend if exists', done => {
+ const msg = 'Custom error message from backend';
+ spyOn(vm.service, 'updateIssuable').and.callFake(
+ // eslint-disable-next-line prefer-promise-reject-errors
+ () => Promise.reject({ response: { data: { errors: [msg] } } }),
+ );
+
+ vm.updateIssuable();
+ setTimeout(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `${vm.defaultErrorMessage}. ${msg}`,
+ );
+
+ done();
+ });
+ });
});
});
@@ -342,21 +352,19 @@ describe('Issuable output', () => {
describe('deleteIssuable', () => {
it('changes URL when deleted', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'deleteIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: '/test',
- },
- });
- }),
+ spyOn(vm.service, 'deleteIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: '/test',
+ },
+ }),
);
vm.deleteIssuable();
setTimeout(() => {
expect(visitUrl).toHaveBeenCalledWith('/test');
+
done();
});
});
@@ -364,40 +372,33 @@ describe('Issuable output', () => {
it('stops polling when deleting', done => {
spyOnDependency(issuableApp, 'visitUrl');
spyOn(vm.poll, 'stop').and.callThrough();
- spyOn(vm.service, 'deleteIssuable').and.callFake(
- () =>
- new Promise(resolve => {
- resolve({
- data: {
- web_url: '/test',
- },
- });
- }),
+ spyOn(vm.service, 'deleteIssuable').and.returnValue(
+ Promise.resolve({
+ data: {
+ web_url: '/test',
+ },
+ }),
);
vm.deleteIssuable();
setTimeout(() => {
expect(vm.poll.stop).toHaveBeenCalledWith();
+
done();
});
});
it('closes form on error', done => {
- spyOn(window, 'Flash').and.callThrough();
- spyOn(vm.service, 'deleteIssuable').and.callFake(
- () =>
- new Promise((resolve, reject) => {
- reject();
- }),
- );
+ spyOn(vm.service, 'deleteIssuable').and.returnValue(Promise.reject());
vm.deleteIssuable();
setTimeout(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
-
- expect(window.Flash).toHaveBeenCalledWith('Error deleting issue');
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error deleting issue',
+ );
done();
});
@@ -420,6 +421,7 @@ describe('Issuable output', () => {
.then(vm.$nextTick)
.then(() => {
expect(vm.formState.lockedWarningVisible).toEqual(true);
+ expect(vm.formState.lock_version).toEqual(1);
expect(vm.$el.querySelector('.alert')).not.toBeNull();
})
.then(done)
@@ -438,4 +440,34 @@ describe('Issuable output', () => {
expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
});
});
+
+ describe('updateStoreState', () => {
+ it('should make a request and update the state of the store', done => {
+ const data = { foo: 1 };
+ spyOn(vm.store, 'updateState');
+ spyOn(vm.service, 'getData').and.returnValue(Promise.resolve({ data }));
+
+ vm.updateStoreState()
+ .then(() => {
+ expect(vm.service.getData).toHaveBeenCalled();
+ expect(vm.store.updateState).toHaveBeenCalledWith(data);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should show error message if store update fails', done => {
+ spyOn(vm.service, 'getData').and.returnValue(Promise.reject());
+ vm.issuableType = 'merge request';
+
+ vm.updateStoreState()
+ .then(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating ${vm.issuableType}`,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js
index 463f3c89926..72716b97f5f 100644
--- a/spec/javascripts/issue_show/components/description_spec.js
+++ b/spec/javascripts/issue_show/components/description_spec.js
@@ -123,7 +123,10 @@ describe('Description component', () => {
fieldName: 'description',
selector: '.detail-page-description',
onSuccess: jasmine.any(Function),
+ onError: jasmine.any(Function),
+ lockVersion: 0,
});
+
done();
});
});
@@ -184,4 +187,18 @@ describe('Description component', () => {
it('sets data-update-url', () => {
expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST);
});
+
+ describe('taskListUpdateError', () => {
+ it('should create flash notification and emit an event to parent', () => {
+ const msg =
+ 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.';
+ spyOn(window, 'Flash');
+ spyOn(vm, '$emit');
+
+ vm.taskListUpdateError();
+
+ expect(window.Flash).toHaveBeenCalledWith(msg);
+ expect(vm.$emit).toHaveBeenCalledWith('taskListUpdateFailed');
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
index 74b3efb014b..f4475aadb8b 100644
--- a/spec/javascripts/issue_show/mock_data.js
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -8,6 +8,7 @@ export default {
updated_at: '2015-05-15T12:31:04.428Z',
updated_by_name: 'Some User',
updated_by_path: '/some_user',
+ lock_version: 1,
},
secondRequest: {
title: '<p>2</p>',
@@ -18,5 +19,6 @@ export default {
updated_at: '2016-05-15T12:31:04.428Z',
updated_by_name: 'Other User',
updated_by_path: '/other_user',
+ lock_version: 2,
},
};
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index e3fd9604474..3eff3f655ee 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -232,6 +232,21 @@ describe('common_utils', () => {
});
});
+ describe('debounceByAnimationFrame', () => {
+ it('debounces a function to allow a maximum of one call per animation frame', done => {
+ const spy = jasmine.createSpy('spy');
+ const debouncedSpy = commonUtils.debounceByAnimationFrame(spy);
+ window.requestAnimationFrame(() => {
+ debouncedSpy();
+ debouncedSpy();
+ window.requestAnimationFrame(() => {
+ expect(spy).toHaveBeenCalledTimes(1);
+ done();
+ });
+ });
+ });
+ });
+
describe('getParameterByName', () => {
beforeEach(() => {
window.history.pushState({}, null, '?scope=all&p=2');
diff --git a/spec/javascripts/lib/utils/grammar_spec.js b/spec/javascripts/lib/utils/grammar_spec.js
new file mode 100644
index 00000000000..377b2ffb48c
--- /dev/null
+++ b/spec/javascripts/lib/utils/grammar_spec.js
@@ -0,0 +1,35 @@
+import * as grammar from '~/lib/utils/grammar';
+
+describe('utils/grammar', () => {
+ describe('toNounSeriesText', () => {
+ it('with empty items returns empty string', () => {
+ expect(grammar.toNounSeriesText([])).toBe('');
+ });
+
+ it('with single item returns item', () => {
+ const items = ['Lorem Ipsum'];
+
+ expect(grammar.toNounSeriesText(items)).toBe(items[0]);
+ });
+
+ it('with 2 items returns item1 and item2', () => {
+ const items = ['Dolar', 'Sit Amit'];
+
+ expect(grammar.toNounSeriesText(items)).toBe(`${items[0]} and ${items[1]}`);
+ });
+
+ it('with 3 items returns comma separated series', () => {
+ const items = ['Lorem', 'Ipsum', 'dolar'];
+ const expected = 'Lorem, Ipsum, and dolar';
+
+ expect(grammar.toNounSeriesText(items)).toBe(expected);
+ });
+
+ it('with 6 items returns comma separated series', () => {
+ const items = ['Lorem', 'ipsum', 'dolar', 'sit', 'amit', 'consectetur'];
+ const expected = 'Lorem, ipsum, dolar, sit, amit, and consectetur';
+
+ expect(grammar.toNounSeriesText(items)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/icon_utils_spec.js b/spec/javascripts/lib/utils/icon_utils_spec.js
new file mode 100644
index 00000000000..3fd3940efe8
--- /dev/null
+++ b/spec/javascripts/lib/utils/icon_utils_spec.js
@@ -0,0 +1,67 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as iconUtils from '~/lib/utils/icon_utils';
+
+describe('Icon utils', () => {
+ describe('getSvgIconPathContent', () => {
+ let spriteIcons;
+
+ beforeAll(() => {
+ spriteIcons = gon.sprite_icons;
+ gon.sprite_icons = 'mockSpriteIconsEndpoint';
+ });
+
+ afterAll(() => {
+ gon.sprite_icons = spriteIcons;
+ });
+
+ let axiosMock;
+ let mockEndpoint;
+ let getIcon;
+ const mockName = 'mockIconName';
+ const mockPath = 'mockPath';
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ mockEndpoint = axiosMock.onGet(gon.sprite_icons);
+ getIcon = iconUtils.getSvgIconPathContent(mockName);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
+ it('extracts svg icon path content from sprite icons', done => {
+ mockEndpoint.replyOnce(
+ 200,
+ `<svg><symbol id="${mockName}"><path d="${mockPath}"/></symbol></svg>`,
+ );
+ getIcon
+ .then(path => {
+ expect(path).toBe(mockPath);
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns null if icon path content does not exist', done => {
+ mockEndpoint.replyOnce(200, ``);
+ getIcon
+ .then(path => {
+ expect(path).toBe(null);
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns null if an http error occurs', done => {
+ mockEndpoint.replyOnce(500);
+ getIcon
+ .then(path => {
+ expect(path).toBe(null);
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js
index 1cb49b49ca7..32623d1781a 100644
--- a/spec/javascripts/merge_request_spec.js
+++ b/spec/javascripts/merge_request_spec.js
@@ -41,15 +41,28 @@ describe('MergeRequest', function() {
});
it('submits an ajax request on tasklist:changed', done => {
- $('.js-task-list-field').trigger('tasklist:changed');
+ const lineNumber = 8;
+ const lineSource = '- [ ] item 8';
+ const index = 3;
+ const checked = true;
+
+ $('.js-task-list-field').trigger({
+ type: 'tasklist:changed',
+ detail: { lineNumber, lineSource, index, checked },
+ });
setTimeout(() => {
expect(axios.patch).toHaveBeenCalledWith(
`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`,
{
- merge_request: { description: '- [ ] Task List Item' },
+ merge_request: {
+ description: '- [ ] Task List Item',
+ lock_version: undefined,
+ update_task: { line_number: lineNumber, line_source: lineSource, index, checked },
+ },
},
);
+
done();
});
});
diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js
index 565b87de248..97b9671c809 100644
--- a/spec/javascripts/monitoring/dashboard_spec.js
+++ b/spec/javascripts/monitoring/dashboard_spec.js
@@ -29,7 +29,7 @@ describe('Dashboard', () => {
beforeEach(() => {
setFixtures(`
<div class="prometheus-graphs"></div>
- <div class="nav-sidebar"></div>
+ <div class="layout-page"></div>
`);
DashboardComponent = Vue.extend(Dashboard);
});
@@ -164,16 +164,16 @@ describe('Dashboard', () => {
jasmine.clock().uninstall();
});
- it('rerenders the dashboard when the sidebar is resized', done => {
+ it('sets elWidth to page width when the sidebar is resized', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
propsData: { ...propsData, hasMetrics: true, showPanels: false },
});
- expect(component.forceRedraw).toEqual(0);
+ expect(component.elWidth).toEqual(0);
- const navSidebarEl = document.querySelector('.nav-sidebar');
- navSidebarEl.classList.add('nav-sidebar-collapsed');
+ const pageLayoutEl = document.querySelector('.layout-page');
+ pageLayoutEl.classList.add('page-with-icon-sidebar');
Vue.nextTick()
.then(() => {
@@ -181,7 +181,7 @@ describe('Dashboard', () => {
return Vue.nextTick();
})
.then(() => {
- expect(component.forceRedraw).toEqual(component.elWidth);
+ expect(component.elWidth).toEqual(pageLayoutEl.clientWidth);
done();
})
.catch(done.fail);
diff --git a/spec/javascripts/monitoring/graph/axis_spec.js b/spec/javascripts/monitoring/graph/axis_spec.js
deleted file mode 100644
index c7adba00637..00000000000
--- a/spec/javascripts/monitoring/graph/axis_spec.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import Vue from 'vue';
-import GraphAxis from '~/monitoring/components/graph/axis.vue';
-import measurements from '~/monitoring/utils/measurements';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphAxis);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const defaultValuesComponent = {
- graphWidth: 500,
- graphHeight: 300,
- graphHeightOffset: 120,
- margin: measurements.large.margin,
- measurements: measurements.large,
- yAxisLabel: 'Values',
- unitOfDisplay: 'MB',
-};
-
-function getTextFromNode(component, selector) {
- return component.$el.querySelector(selector).firstChild.nodeValue.trim();
-}
-
-describe('Axis', () => {
- describe('Computed props', () => {
- it('textTransform', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
- });
-
- it('xPosition', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.xPosition).toEqual(180);
- });
-
- it('yPosition', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.yPosition).toEqual(240);
- });
-
- it('rectTransform', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)');
- });
- });
-
- it('has 2 rect-axis-text rect svg elements', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
- });
-
- it('contains text to signal the usage, title and time with multiple time series', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(getTextFromNode(component, '.y-label-text')).toEqual('Values (MB)');
- });
-});
diff --git a/spec/javascripts/monitoring/graph/deployment_spec.js b/spec/javascripts/monitoring/graph/deployment_spec.js
deleted file mode 100644
index 7d39c4345d2..00000000000
--- a/spec/javascripts/monitoring/graph/deployment_spec.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import Vue from 'vue';
-import GraphDeployment from '~/monitoring/components/graph/deployment.vue';
-import { deploymentData } from '../mock_data';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphDeployment);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-describe('MonitoringDeployment', () => {
- describe('Methods', () => {
- it('should contain a hidden gradient', () => {
- const component = createComponent({
- showDeployInfo: true,
- deploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull();
- });
-
- it('transformDeploymentGroup translates an available deployment', () => {
- const component = createComponent({
- showDeployInfo: false,
- deploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.transformDeploymentGroup({ xPos: 16 })).toContain('translate(11, 20)');
- });
-
- describe('Computed props', () => {
- it('calculatedHeight', () => {
- const component = createComponent({
- showDeployInfo: true,
- deploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.calculatedHeight).toEqual(180);
- });
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js
deleted file mode 100644
index 038bfffd44f..00000000000
--- a/spec/javascripts/monitoring/graph/flag_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import Vue from 'vue';
-import GraphFlag from '~/monitoring/components/graph/flag.vue';
-import { deploymentData } from '../mock_data';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphFlag);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const defaultValuesComponent = {
- currentXCoordinate: 200,
- currentYCoordinate: 100,
- currentFlagPosition: 100,
- currentData: {
- time: new Date('2017-06-04T18:17:33.501Z'),
- value: '1.49609375',
- },
- graphHeight: 300,
- graphHeightOffset: 120,
- showFlagContent: true,
- realPixelRatio: 1,
- timeSeries: [
- {
- values: [
- {
- time: new Date('2017-06-04T18:17:33.501Z'),
- value: '1.49609375',
- },
- ],
- },
- ],
- unitOfDisplay: 'ms',
- currentDataIndex: 0,
- legendTitle: 'Average',
- currentCoordinates: {},
-};
-
-const deploymentFlagData = {
- ...deploymentData[0],
- ref: deploymentData[0].ref.name,
- xPos: 10,
- time: new Date(deploymentData[0].created_at),
-};
-
-describe('GraphFlag', () => {
- let component;
-
- it('has a line at the currentXCoordinate', () => {
- component = createComponent(defaultValuesComponent);
-
- expect(component.$el.style.left).toEqual(`${70 + component.currentXCoordinate}px`);
- });
-
- describe('Deployment flag', () => {
- it('shows a deployment flag when deployment data provided', () => {
- const deploymentFlagComponent = createComponent({
- ...defaultValuesComponent,
- deploymentFlagData,
- });
-
- expect(deploymentFlagComponent.$el.querySelector('.popover-title')).toContainText('Deployed');
- });
-
- it('contains the ref when a tag is available', () => {
- const deploymentFlagComponent = createComponent({
- ...defaultValuesComponent,
- deploymentFlagData: {
- ...deploymentFlagData,
- sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
- tag: true,
- ref: '1.0',
- },
- });
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
- 'f5bcd1d9',
- );
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
- '1.0',
- );
- });
-
- it('does not contain the ref when a tag is unavailable', () => {
- const deploymentFlagComponent = createComponent({
- ...defaultValuesComponent,
- deploymentFlagData: {
- ...deploymentFlagData,
- sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
- tag: false,
- ref: '1.0',
- },
- });
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
- 'f5bcd1d9',
- );
-
- expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).not.toContainText(
- '1.0',
- );
- });
- });
-
- describe('Computed props', () => {
- beforeEach(() => {
- component = createComponent(defaultValuesComponent);
- });
-
- it('formatTime', () => {
- expect(component.formatTime).toMatch(/\d:17PM/);
- });
-
- it('formatDate', () => {
- expect(component.formatDate).toEqual('04 Jun 2017, ');
- });
-
- it('cursorStyle', () => {
- expect(component.cursorStyle).toEqual({
- top: '20px',
- left: '270px',
- height: '180px',
- });
- });
-
- it('flagOrientation', () => {
- expect(component.flagOrientation).toEqual('left');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/legend_spec.js b/spec/javascripts/monitoring/graph/legend_spec.js
deleted file mode 100644
index 9209e77dcf4..00000000000
--- a/spec/javascripts/monitoring/graph/legend_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import Vue from 'vue';
-import GraphLegend from '~/monitoring/components/graph/legend.vue';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-
-const defaultValuesComponent = {};
-
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
-
-defaultValuesComponent.timeSeries = timeSeries;
-
-describe('Legend Component', () => {
- let vm;
- let Legend;
-
- beforeEach(() => {
- Legend = Vue.extend(GraphLegend);
- });
-
- describe('View', () => {
- beforeEach(() => {
- vm = mountComponent(Legend, {
- legendTitle: 'legend',
- timeSeries,
- currentDataIndex: 0,
- unitOfDisplay: 'Req/Sec',
- });
- });
-
- it('should render the usage, title and time with multiple time series', () => {
- const titles = vm.$el.querySelectorAll('.legend-metric-title');
-
- expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1);
- expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1);
- });
-
- it('should container the same number of rows in the table as time series', () => {
- expect(vm.$el.querySelectorAll('.prometheus-table tr').length).toEqual(vm.timeSeries.length);
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/track_info_spec.js b/spec/javascripts/monitoring/graph/track_info_spec.js
deleted file mode 100644
index ce93ae28842..00000000000
--- a/spec/javascripts/monitoring/graph/track_info_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import Vue from 'vue';
-import TrackInfo from '~/monitoring/components/graph/track_info.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
-
-describe('TrackInfo component', () => {
- let vm;
- let Component;
-
- beforeEach(() => {
- Component = Vue.extend(TrackInfo);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('Computed props', () => {
- beforeEach(() => {
- vm = mountComponent(Component, { track: timeSeries[0] });
- });
-
- it('summaryMetrics', () => {
- expect(vm.summaryMetrics).toEqual('Avg: 0.000 · Max: 0.000');
- });
- });
-
- describe('Rendered output', () => {
- beforeEach(() => {
- vm = mountComponent(Component, { track: timeSeries[0] });
- });
-
- it('contains metric tag and the summary metrics', () => {
- const metricTag = vm.$el.querySelector('strong');
-
- expect(metricTag.textContent.trim()).toEqual(vm.track.metricTag);
- expect(vm.$el.textContent).toContain('Avg: 0.000 · Max: 0.000');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph/track_line_spec.js b/spec/javascripts/monitoring/graph/track_line_spec.js
deleted file mode 100644
index 2a4f89ddf6e..00000000000
--- a/spec/javascripts/monitoring/graph/track_line_spec.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import Vue from 'vue';
-import TrackLine from '~/monitoring/components/graph/track_line.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
-
-describe('TrackLine component', () => {
- let vm;
- let Component;
-
- beforeEach(() => {
- Component = Vue.extend(TrackLine);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('Computed props', () => {
- it('stylizedLine for dashed lineStyles', () => {
- vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dashed' } });
-
- expect(vm.stylizedLine).toEqual('6, 3');
- });
-
- it('stylizedLine for dotted lineStyles', () => {
- vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dotted' } });
-
- expect(vm.stylizedLine).toEqual('3, 3');
- });
- });
-
- describe('Rendered output', () => {
- it('has an svg with a line', () => {
- vm = mountComponent(Component, { track: { ...timeSeries[0] } });
- const svgEl = vm.$el.querySelector('svg');
- const lineEl = vm.$el.querySelector('svg line');
-
- expect(svgEl.getAttribute('width')).toEqual('16');
- expect(svgEl.getAttribute('height')).toEqual('8');
-
- expect(lineEl.getAttribute('stroke-width')).toEqual('4');
- expect(lineEl.getAttribute('x1')).toEqual('0');
- expect(lineEl.getAttribute('x2')).toEqual('16');
- expect(lineEl.getAttribute('y1')).toEqual('4');
- expect(lineEl.getAttribute('y2')).toEqual('4');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph_path_spec.js b/spec/javascripts/monitoring/graph_path_spec.js
deleted file mode 100644
index fd167b83d51..00000000000
--- a/spec/javascripts/monitoring/graph_path_spec.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import Vue from 'vue';
-import GraphPath from '~/monitoring/components/graph/path.vue';
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from './mock_data';
-
-const createComponent = propsData => {
- const Component = Vue.extend(GraphPath);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
-const firstTimeSeries = timeSeries[0];
-
-describe('Monitoring Paths', () => {
- it('renders two paths to represent a line and the area underneath it', () => {
- const component = createComponent({
- generatedLinePath: firstTimeSeries.linePath,
- generatedAreaPath: firstTimeSeries.areaPath,
- lineColor: firstTimeSeries.lineColor,
- areaColor: firstTimeSeries.areaColor,
- showDot: false,
- });
- const metricArea = component.$el.querySelector('.metric-area');
- const metricLine = component.$el.querySelector('.metric-line');
-
- expect(metricArea.getAttribute('fill')).toBe('#8fbce8');
- expect(metricArea.getAttribute('d')).toBe(firstTimeSeries.areaPath);
- expect(metricLine.getAttribute('stroke')).toBe('#1f78d1');
- expect(metricLine.getAttribute('d')).toBe(firstTimeSeries.linePath);
- });
-
- describe('Computed properties', () => {
- it('strokeDashArray', () => {
- const component = createComponent({
- generatedLinePath: firstTimeSeries.linePath,
- generatedAreaPath: firstTimeSeries.areaPath,
- lineColor: firstTimeSeries.lineColor,
- areaColor: firstTimeSeries.areaColor,
- showDot: false,
- });
-
- component.lineStyle = 'dashed';
-
- expect(component.strokeDashArray).toBe('3, 1');
-
- component.lineStyle = 'dotted';
-
- expect(component.strokeDashArray).toBe('1, 1');
- });
- });
-});
diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js
deleted file mode 100644
index 59d6d4f3a7f..00000000000
--- a/spec/javascripts/monitoring/graph_spec.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import Vue from 'vue';
-import Graph from '~/monitoring/components/graph.vue';
-import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
-import {
- deploymentData,
- convertDatesMultipleSeries,
- singleRowMetricsMultipleSeries,
- queryWithoutData,
-} from './mock_data';
-
-const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags';
-const projectPath = 'http://test.host/frontend-fixtures/environments-project';
-const createComponent = propsData => {
- const Component = Vue.extend(Graph);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-
-describe('Graph', () => {
- beforeEach(() => {
- spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({});
- });
-
- it('has a title', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- expect(component.$el.querySelector('.prometheus-graph-title').innerText.trim()).toBe(
- component.graphData.title,
- );
- });
-
- describe('Computed props', () => {
- it('axisTransform translates an element Y position depending of its height', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- const transformedHeight = `${component.graphHeight - 100}`;
-
- expect(component.axisTransform.indexOf(transformedHeight)).not.toEqual(-1);
- });
-
- it('outerViewBox gets a width and height property based on the DOM size of the element', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- const viewBoxArray = component.outerViewBox.split(' ');
-
- expect(typeof component.outerViewBox).toEqual('string');
- expect(viewBoxArray[2]).toEqual(component.graphWidth.toString());
- expect(viewBoxArray[3]).toEqual((component.graphHeight - 50).toString());
- });
- });
-
- it('has a title for the y-axis and the chart legend that comes from the backend', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- expect(component.yAxisLabel).toEqual(component.graphData.y_label);
- expect(component.legendTitle).toEqual(component.graphData.queries[0].label);
- });
-
- it('sets the currentData object based on the hovered data index', () => {
- const component = createComponent({
- graphData: convertedMetrics[1],
- updateAspectRatio: false,
- deploymentData,
- graphIdentifier: 0,
- hoverData: {
- hoveredDate: new Date('Sun Aug 27 2017 06:11:51 GMT-0500 (CDT)'),
- currentDeployXPos: null,
- },
- tagsPath,
- projectPath,
- });
-
- // simulate moving mouse over data series
- component.seriesUnderMouse = component.timeSeries;
-
- component.positionFlag();
-
- expect(component.currentData).toBe(component.timeSeries[0].values[10]);
- });
-
- describe('Without data to display', () => {
- it('shows a "no data to display" empty state on a graph', done => {
- const component = createComponent({
- graphData: queryWithoutData,
- deploymentData,
- tagsPath,
- projectPath,
- });
-
- Vue.nextTick(() => {
- expect(
- component.$el.querySelector('.js-no-data-to-display text').textContent.trim(),
- ).toEqual('No data to display');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js
deleted file mode 100644
index 8937b7d9680..00000000000
--- a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import createTimeSeries from '~/monitoring/utils/multiple_time_series';
-import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data';
-
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
-const firstTimeSeries = timeSeries[0];
-
-describe('Multiple time series', () => {
- it('createTimeSeries returned array contains an object for each element', () => {
- expect(typeof firstTimeSeries.linePath).toEqual('string');
- expect(typeof firstTimeSeries.areaPath).toEqual('string');
- expect(typeof firstTimeSeries.timeSeriesScaleX).toEqual('function');
- expect(typeof firstTimeSeries.areaColor).toEqual('string');
- expect(typeof firstTimeSeries.lineColor).toEqual('string');
- expect(firstTimeSeries.values instanceof Array).toEqual(true);
- });
-
- it('createTimeSeries returns an array', () => {
- expect(timeSeries instanceof Array).toEqual(true);
- expect(timeSeries.length).toEqual(2);
- });
-});
diff --git a/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js b/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js
new file mode 100644
index 00000000000..07a366cf339
--- /dev/null
+++ b/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js
@@ -0,0 +1,34 @@
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+const localVue = createLocalVue();
+
+describe('ReplyPlaceholder', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(ReplyPlaceholder, {
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('emits onClick even on button click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(wrapper.emitted()).toEqual({
+ onClick: [[]],
+ });
+ });
+
+ it('should render reply button', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ expect(button.text()).toEqual('Reply...');
+ });
+});
diff --git a/spec/javascripts/notes/components/note_actions/reply_button_spec.js b/spec/javascripts/notes/components/note_actions/reply_button_spec.js
new file mode 100644
index 00000000000..11e1664a3f4
--- /dev/null
+++ b/spec/javascripts/notes/components/note_actions/reply_button_spec.js
@@ -0,0 +1,46 @@
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
+import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
+
+describe('ReplyButton', () => {
+ const noteId = 'dummy-note-id';
+
+ let wrapper;
+ let convertToDiscussion;
+
+ beforeEach(() => {
+ const localVue = createLocalVue();
+ convertToDiscussion = jasmine.createSpy('convertToDiscussion');
+
+ localVue.use(Vuex);
+ const store = new Vuex.Store({
+ actions: {
+ convertToDiscussion,
+ },
+ });
+
+ wrapper = mount(ReplyButton, {
+ propsData: {
+ noteId,
+ },
+ store,
+ sync: false,
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('dispatches convertToDiscussion with note ID on click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(convertToDiscussion).toHaveBeenCalledTimes(1);
+ const [, payload] = convertToDiscussion.calls.argsFor(0);
+
+ expect(payload).toBe(noteId);
+ });
+});
diff --git a/spec/javascripts/notes/components/note_actions_spec.js b/spec/javascripts/notes/components/note_actions_spec.js
index b102b7aecf7..0c1962912b4 100644
--- a/spec/javascripts/notes/components/note_actions_spec.js
+++ b/spec/javascripts/notes/components/note_actions_spec.js
@@ -2,14 +2,38 @@ import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import noteActions from '~/notes/components/note_actions.vue';
+import { TEST_HOST } from 'spec/test_constants';
import { userDataMock } from '../mock_data';
describe('noteActions', () => {
let wrapper;
let store;
+ let props;
+
+ const createWrapper = propsData => {
+ const localVue = createLocalVue();
+ return shallowMount(noteActions, {
+ store,
+ propsData,
+ localVue,
+ sync: false,
+ });
+ };
beforeEach(() => {
store = createStore();
+ props = {
+ accessLevel: 'Maintainer',
+ authorId: 26,
+ canDelete: true,
+ canEdit: true,
+ canAwardEmoji: true,
+ canReportAsAbuse: true,
+ noteId: '539',
+ noteUrl: `${TEST_HOST}/group/project/merge_requests/1#note_1`,
+ reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`,
+ showReply: false,
+ };
});
afterEach(() => {
@@ -17,31 +41,10 @@ describe('noteActions', () => {
});
describe('user is logged in', () => {
- let props;
-
beforeEach(() => {
- props = {
- accessLevel: 'Maintainer',
- authorId: 26,
- canDelete: true,
- canEdit: true,
- canAwardEmoji: true,
- canReportAsAbuse: true,
- noteId: '539',
- noteUrl: 'https://localhost:3000/group/project/merge_requests/1#note_1',
- 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);
- const localVue = createLocalVue();
- wrapper = shallowMount(noteActions, {
- store,
- propsData: props,
- localVue,
- sync: false,
- });
+ wrapper = createWrapper(props);
});
it('should render access level badge', () => {
@@ -91,28 +94,14 @@ describe('noteActions', () => {
});
describe('user is not logged in', () => {
- let props;
-
beforeEach(() => {
store.dispatch('setUserData', {});
- props = {
- accessLevel: 'Maintainer',
- authorId: 26,
+ wrapper = createWrapper({
+ ...props,
canDelete: false,
canEdit: false,
canAwardEmoji: false,
canReportAsAbuse: false,
- noteId: '539',
- noteUrl: 'https://localhost:3000/group/project/merge_requests/1#note_1',
- reportAbusePath:
- '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
- };
- const localVue = createLocalVue();
- wrapper = shallowMount(noteActions, {
- store,
- propsData: props,
- localVue,
- sync: false,
});
});
@@ -124,4 +113,88 @@ describe('noteActions', () => {
expect(wrapper.find('.more-actions').exists()).toBe(false);
});
});
+
+ describe('with feature flag replyToIndividualNotes enabled', () => {
+ beforeEach(() => {
+ gon.features = {
+ replyToIndividualNotes: true,
+ };
+ });
+
+ afterEach(() => {
+ gon.features = {};
+ });
+
+ describe('for showReply = true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: true,
+ });
+ });
+
+ it('shows a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(true);
+ });
+ });
+
+ describe('for showReply = false', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: false,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with feature flag replyToIndividualNotes disabled', () => {
+ beforeEach(() => {
+ gon.features = {
+ replyToIndividualNotes: false,
+ };
+ });
+
+ afterEach(() => {
+ gon.features = {};
+ });
+
+ describe('for showReply = true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: true,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+
+ describe('for showReply = false', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: false,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js
index 22bee049f9c..d5c0bf6b25d 100644
--- a/spec/javascripts/notes/components/note_app_spec.js
+++ b/spec/javascripts/notes/components/note_app_spec.js
@@ -1,57 +1,49 @@
import $ from 'jquery';
import _ from 'underscore';
import Vue from 'vue';
-import notesApp from '~/notes/components/notes_app.vue';
+import { mount, createLocalVue } from '@vue/test-utils';
+import NotesApp from '~/notes/components/notes_app.vue';
import service from '~/notes/services/notes_service';
import createStore from '~/notes/stores';
import '~/behaviors/markdown/render_gfm';
-import { mountComponentWithStore } from 'spec/helpers';
import * as mockData from '../mock_data';
-const vueMatchers = {
- toIncludeElement() {
- return {
- compare(vm, selector) {
- const result = {
- pass: vm.$el.querySelector(selector) !== null,
- };
- return result;
- },
- };
- },
-};
-
describe('note_app', () => {
let mountComponent;
- let vm;
+ let wrapper;
let store;
beforeEach(() => {
- jasmine.addMatchers(vueMatchers);
$('body').attr('data-page', 'projects:merge_requests:show');
- setFixtures('<div class="js-vue-notes-event"><div id="app"></div></div>');
-
- const IssueNotesApp = Vue.extend(notesApp);
-
store = createStore();
mountComponent = data => {
- const props = data || {
+ const propsData = data || {
noteableData: mockData.noteableDataMock,
notesData: mockData.notesDataMock,
userData: mockData.userDataMock,
};
-
- return mountComponentWithStore(IssueNotesApp, {
- props,
- store,
- el: document.getElementById('app'),
- });
+ const localVue = createLocalVue();
+
+ return mount(
+ {
+ components: {
+ NotesApp,
+ },
+ template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>',
+ },
+ {
+ propsData,
+ store,
+ localVue,
+ sync: false,
+ },
+ );
};
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('set data', () => {
@@ -65,7 +57,7 @@ describe('note_app', () => {
beforeEach(() => {
Vue.http.interceptors.push(responseInterceptor);
- vm = mountComponent();
+ wrapper = mountComponent();
});
afterEach(() => {
@@ -73,26 +65,26 @@ describe('note_app', () => {
});
it('should set notes data', () => {
- expect(vm.$store.state.notesData).toEqual(mockData.notesDataMock);
+ expect(store.state.notesData).toEqual(mockData.notesDataMock);
});
it('should set issue data', () => {
- expect(vm.$store.state.noteableData).toEqual(mockData.noteableDataMock);
+ expect(store.state.noteableData).toEqual(mockData.noteableDataMock);
});
it('should set user data', () => {
- expect(vm.$store.state.userData).toEqual(mockData.userDataMock);
+ expect(store.state.userData).toEqual(mockData.userDataMock);
});
it('should fetch discussions', () => {
- expect(vm.$store.state.discussions).toEqual([]);
+ expect(store.state.discussions).toEqual([]);
});
});
describe('render', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
- vm = mountComponent();
+ wrapper = mountComponent();
});
afterEach(() => {
@@ -107,51 +99,50 @@ describe('note_app', () => {
setTimeout(() => {
expect(
- vm.$el.querySelector('.main-notes-list .note-header-author-name').textContent.trim(),
+ wrapper
+ .find('.main-notes-list .note-header-author-name')
+ .text()
+ .trim(),
).toEqual(note.author.name);
- expect(vm.$el.querySelector('.main-notes-list .note-text').innerHTML).toEqual(
- note.note_html,
- );
+ expect(wrapper.find('.main-notes-list .note-text').html()).toContain(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…');
+ expect(wrapper.find('.js-main-target-form').name()).toEqual('form');
+ expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
+ 'Write a comment or drag your files here…',
+ );
});
it('should not render form when commenting is disabled', () => {
store.state.commentsDisabled = true;
- vm = mountComponent();
+ wrapper = mountComponent();
- expect(vm.$el.querySelector('.js-main-target-form')).toEqual(null);
+ expect(wrapper.find('.js-main-target-form').exists()).toBe(false);
});
it('should render form comment button as disabled', () => {
- expect(vm.$el.querySelector('.js-note-new-discussion').getAttribute('disabled')).toEqual(
- 'disabled',
- );
+ expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled');
});
});
describe('while fetching data', () => {
beforeEach(() => {
- vm = mountComponent();
+ wrapper = mountComponent();
});
it('renders skeleton notes', () => {
- expect(vm).toIncludeElement('.animation-container');
+ expect(wrapper.find('.animation-container').exists()).toBe(true);
});
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…');
+ expect(wrapper.find('.js-main-target-form').name()).toEqual('form');
+ expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
+ 'Write a comment or drag your files here…',
+ );
});
});
@@ -160,9 +151,9 @@ describe('note_app', () => {
beforeEach(done => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
spyOn(service, 'updateNote').and.callThrough();
- vm = mountComponent();
+ wrapper = mountComponent();
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
Vue.nextTick(done);
}, 0);
});
@@ -175,12 +166,12 @@ describe('note_app', () => {
});
it('renders edit form', () => {
- expect(vm).toIncludeElement('.js-vue-issue-note-form');
+ expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true);
});
it('calls the service to update the note', done => {
- vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
- vm.$el.querySelector('.js-vue-issue-save').click();
+ wrapper.find('.js-vue-issue-note-form').value = 'this is a note';
+ wrapper.find('.js-vue-issue-save').trigger('click');
expect(service.updateNote).toHaveBeenCalled();
// Wait for the requests to finish before destroying
@@ -194,10 +185,10 @@ describe('note_app', () => {
beforeEach(done => {
Vue.http.interceptors.push(mockData.discussionNoteInterceptor);
spyOn(service, 'updateNote').and.callThrough();
- vm = mountComponent();
+ wrapper = mountComponent();
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
Vue.nextTick(done);
}, 0);
});
@@ -210,12 +201,12 @@ describe('note_app', () => {
});
it('renders edit form', () => {
- expect(vm).toIncludeElement('.js-vue-issue-note-form');
+ expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true);
});
it('updates the note and resets the edit form', done => {
- vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
- vm.$el.querySelector('.js-vue-issue-save').click();
+ wrapper.find('.js-vue-issue-note-form').value = 'this is a note';
+ wrapper.find('.js-vue-issue-save').trigger('click');
expect(service.updateNote).toHaveBeenCalled();
// Wait for the requests to finish before destroying
@@ -228,30 +219,36 @@ describe('note_app', () => {
describe('new note form', () => {
beforeEach(() => {
- vm = mountComponent();
+ wrapper = mountComponent();
});
it('should render markdown docs url', () => {
const { markdownDocsPath } = mockData.notesDataMock;
- expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual(
- 'Markdown',
- );
+ expect(
+ wrapper
+ .find(`a[href="${markdownDocsPath}"]`)
+ .text()
+ .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',
- );
+ expect(
+ wrapper
+ .find(`a[href="${quickActionsDocsPath}"]`)
+ .text()
+ .trim(),
+ ).toEqual('quick actions');
});
});
describe('edit form', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
- vm = mountComponent();
+ wrapper = mountComponent();
});
afterEach(() => {
@@ -260,12 +257,15 @@ describe('note_app', () => {
it('should render markdown docs url', done => {
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
const { markdownDocsPath } = mockData.notesDataMock;
Vue.nextTick(() => {
expect(
- vm.$el.querySelector(`.edit-note a[href="${markdownDocsPath}"]`).textContent.trim(),
+ wrapper
+ .find(`.edit-note a[href="${markdownDocsPath}"]`)
+ .text()
+ .trim(),
).toEqual('Markdown is supported');
done();
});
@@ -274,13 +274,11 @@ describe('note_app', () => {
it('should not render quick actions docs url', done => {
setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
+ wrapper.find('.js-note-edit').trigger('click');
const { quickActionsDocsPath } = mockData.notesDataMock;
Vue.nextTick(() => {
- expect(vm.$el.querySelector(`.edit-note a[href="${quickActionsDocsPath}"]`)).toEqual(
- null,
- );
+ expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false);
done();
});
}, 0);
@@ -295,12 +293,19 @@ describe('note_app', () => {
noteId: 1,
},
});
+ const toggleAwardAction = jasmine.createSpy('toggleAward');
+ wrapper.vm.$store.hotUpdate({
+ actions: {
+ toggleAward: toggleAwardAction,
+ },
+ });
- spyOn(vm.$store, 'dispatch');
+ wrapper.vm.$parent.$el.dispatchEvent(toggleAwardEvent);
- vm.$el.parentElement.dispatchEvent(toggleAwardEvent);
+ expect(toggleAwardAction).toHaveBeenCalledTimes(1);
+ const [, payload] = toggleAwardAction.calls.argsFor(0);
- expect(vm.$store.dispatch).toHaveBeenCalledWith('toggleAward', {
+ expect(payload).toEqual({
awardName: 'test',
noteId: 1,
});
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index c4b7eb17393..2eae22e095f 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -1,6 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import '~/behaviors/markdown/render_gfm';
import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
import mockDiffFile from '../../diffs/mock_data/diff_file';
@@ -57,27 +58,23 @@ describe('noteable_discussion component', () => {
});
describe('actions', () => {
- it('should render reply button', () => {
- expect(
- wrapper
- .find('.js-vue-discussion-reply')
- .text()
- .trim(),
- ).toEqual('Reply...');
- });
-
it('should toggle reply form', done => {
- wrapper.find('.js-vue-discussion-reply').trigger('click');
+ const replyPlaceholder = wrapper.find(ReplyPlaceholder);
- wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.isReplying).toEqual(true);
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.vm.isReplying).toEqual(false);
- // There is a watcher for `isReplying` which will init autosave in the next tick
- wrapper.vm.$nextTick(() => {
+ replyPlaceholder.vm.$emit('onClick');
+ })
+ .then(() => wrapper.vm.$nextTick())
+ .then(() => {
+ expect(wrapper.vm.isReplying).toEqual(true);
expect(wrapper.vm.$refs.noteForm).not.toBeNull();
- done();
- });
- });
+ })
+ .then(done)
+ .catch(done.fail);
});
it('does not render jump to discussion button', () => {
diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js
index 8ade6fc2ced..4a143ef089a 100644
--- a/spec/javascripts/notes/components/noteable_note_spec.js
+++ b/spec/javascripts/notes/components/noteable_note_spec.js
@@ -1,64 +1,105 @@
-import $ from 'jquery';
import _ from 'underscore';
-import Vue from 'vue';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import issueNote from '~/notes/components/noteable_note.vue';
+import NoteHeader from '~/notes/components/note_header.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import NoteActions from '~/notes/components/note_actions.vue';
+import NoteBody from '~/notes/components/note_body.vue';
import { noteableDataMock, notesDataMock, note } from '../mock_data';
describe('issue_note', () => {
let store;
- let vm;
+ let wrapper;
beforeEach(() => {
- const Component = Vue.extend(issueNote);
-
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
- vm = new Component({
+ const localVue = createLocalVue();
+ wrapper = shallowMount(issueNote, {
store,
propsData: {
note,
},
- }).$mount();
+ sync: false,
+ localVue,
+ });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('should render user information', () => {
- expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(
- note.author.avatar_url,
- );
+ const { author } = note;
+ const avatar = wrapper.find(UserAvatarLink);
+ const avatarProps = avatar.props();
+
+ expect(avatarProps.linkHref).toBe(author.path);
+ expect(avatarProps.imgSrc).toBe(author.avatar_url);
+ expect(avatarProps.imgAlt).toBe(author.name);
+ expect(avatarProps.imgSize).toBe(40);
});
it('should render note header content', () => {
- const el = vm.$el.querySelector('.note-header .note-header-author-name');
+ const noteHeader = wrapper.find(NoteHeader);
+ const noteHeaderProps = noteHeader.props();
- expect(el.textContent.trim()).toEqual(note.author.name);
+ expect(noteHeaderProps.author).toEqual(note.author);
+ expect(noteHeaderProps.createdAt).toEqual(note.created_at);
+ expect(noteHeaderProps.noteId).toEqual(note.id);
});
it('should render note actions', () => {
- expect(vm.$el.querySelector('.note-actions')).toBeDefined();
+ const { author } = note;
+ const noteActions = wrapper.find(NoteActions);
+ const noteActionsProps = noteActions.props();
+
+ expect(noteActionsProps.authorId).toBe(author.id);
+ expect(noteActionsProps.noteId).toBe(note.id);
+ expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url);
+ expect(noteActionsProps.accessLevel).toBe(note.human_access);
+ expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit);
+ expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji);
+ expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit);
+ expect(noteActionsProps.canReportAsAbuse).toBe(true);
+ expect(noteActionsProps.canResolve).toBe(false);
+ expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path);
+ expect(noteActionsProps.resolvable).toBe(false);
+ expect(noteActionsProps.isResolved).toBe(false);
+ expect(noteActionsProps.isResolving).toBe(false);
+ expect(noteActionsProps.resolvedBy).toEqual({});
});
it('should render issue body', () => {
- expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
+ const noteBody = wrapper.find(NoteBody);
+ const noteBodyProps = noteBody.props();
+
+ expect(noteBodyProps.note).toEqual(note);
+ expect(noteBodyProps.line).toBe(null);
+ expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
+ expect(noteBodyProps.isEditing).toBe(false);
+ expect(noteBodyProps.helpPagePath).toBe('');
});
it('prevents note preview xss', done => {
const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`;
const alertSpy = spyOn(window, 'alert');
- vm.updateNote = () => new Promise($.noop);
+ store.hotUpdate({
+ actions: {
+ updateNote() {},
+ },
+ });
+ const noteBodyComponent = wrapper.find(NoteBody);
- vm.formUpdateHandler(noteBody, null, $.noop);
+ noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
setTimeout(() => {
expect(alertSpy).not.toHaveBeenCalled();
- expect(vm.note.note_html).toEqual(_.escape(noteBody));
+ expect(wrapper.vm.note.note_html).toEqual(_.escape(noteBody));
done();
}, 0);
});
@@ -66,17 +107,23 @@ describe('issue_note', () => {
describe('cancel edit', () => {
it('restores content of updated note', done => {
const noteBody = 'updated note text';
- vm.updateNote = () => Promise.resolve();
+ store.hotUpdate({
+ actions: {
+ updateNote() {},
+ },
+ });
+ const noteBodyComponent = wrapper.find(NoteBody);
+ noteBodyComponent.vm.resetAutoSave = () => {};
- vm.formUpdateHandler(noteBody, null, $.noop);
+ noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
setTimeout(() => {
- expect(vm.note.note_html).toEqual(noteBody);
+ expect(wrapper.vm.note.note_html).toEqual(noteBody);
- vm.formCancelHandler();
+ noteBodyComponent.vm.$emit('cancelForm');
setTimeout(() => {
- expect(vm.note.note_html).toEqual(noteBody);
+ expect(wrapper.vm.note.note_html).toEqual(noteBody);
done();
});
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index 2e3cd5e8f36..73f960dd21e 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -585,4 +585,18 @@ describe('Actions Notes Store', () => {
);
});
});
+
+ describe('convertToDiscussion', () => {
+ it('commits CONVERT_TO_DISCUSSION with noteId', done => {
+ const noteId = 'dummy-note-id';
+ testAction(
+ actions.convertToDiscussion,
+ noteId,
+ {},
+ [{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }],
+ [],
+ done,
+ );
+ });
+ });
});
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
index b6b2c7d60a5..4f8d3069bb5 100644
--- a/spec/javascripts/notes/stores/mutation_spec.js
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -517,4 +517,27 @@ describe('Notes Store mutations', () => {
);
});
});
+
+ describe('CONVERT_TO_DISCUSSION', () => {
+ let discussion;
+ let state;
+
+ beforeEach(() => {
+ discussion = {
+ id: 42,
+ individual_note: true,
+ };
+ state = { discussions: [discussion] };
+ });
+
+ it('toggles individual_note', () => {
+ mutations.CONVERT_TO_DISCUSSION(state, discussion.id);
+
+ expect(discussion.individual_note).toBe(false);
+ });
+
+ it('throws if discussion was not found', () => {
+ expect(() => mutations.CONVERT_TO_DISCUSSION(state, 99)).toThrow();
+ });
+ });
});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 694f581150f..7c869d4c326 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -89,10 +89,25 @@ describe('Notes', function() {
});
it('submits an ajax request on tasklist:changed', function(done) {
- $('.js-task-list-container').trigger('tasklist:changed');
+ const lineNumber = 8;
+ const lineSource = '- [ ] item 8';
+ const index = 3;
+ const checked = true;
+
+ $('.js-task-list-container').trigger({
+ type: 'tasklist:changed',
+ detail: { lineNumber, lineSource, index, checked },
+ });
setTimeout(() => {
- expect(axios.patch).toHaveBeenCalled();
+ expect(axios.patch).toHaveBeenCalledWith(undefined, {
+ note: {
+ note: '',
+ lock_version: undefined,
+ update_task: { index, checked, line_number: lineNumber, line_source: lineSource },
+ },
+ });
+
done();
});
});
diff --git a/spec/javascripts/serverless/components/environment_row_spec.js b/spec/javascripts/serverless/components/environment_row_spec.js
new file mode 100644
index 00000000000..bdf7a714910
--- /dev/null
+++ b/spec/javascripts/serverless/components/environment_row_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+
+import environmentRowComponent from '~/serverless/components/environment_row.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import ServerlessStore from '~/serverless/stores/serverless_store';
+
+import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+
+const createComponent = (env, envName) =>
+ mountComponent(Vue.extend(environmentRowComponent), { env, envName });
+
+describe('environment row component', () => {
+ describe('default global cluster case', () => {
+ let vm;
+
+ beforeEach(() => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctions);
+ vm = createComponent(store.state.functions['*'], '*');
+ });
+
+ it('has the correct envId', () => {
+ expect(vm.envId).toEqual('env-global');
+ vm.$destroy();
+ });
+
+ it('is open by default', () => {
+ expect(vm.isOpenClass).toEqual({ 'is-open': true });
+ vm.$destroy();
+ });
+
+ it('generates correct output', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(2);
+ expect(vm.$el.id).toEqual('env-global');
+ expect(vm.$el.classList.contains('is-open')).toBe(true);
+ expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
+
+ vm.$destroy();
+ });
+
+ it('opens and closes correctly', () => {
+ expect(vm.isOpen).toBe(true);
+
+ vm.toggleOpen();
+ Vue.nextTick(() => {
+ expect(vm.isOpen).toBe(false);
+ });
+
+ vm.$destroy();
+ });
+ });
+
+ describe('default named cluster case', () => {
+ let vm;
+
+ beforeEach(() => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
+ vm = createComponent(store.state.functions.test, 'test');
+ });
+
+ it('has the correct envId', () => {
+ expect(vm.envId).toEqual('env-test');
+ vm.$destroy();
+ });
+
+ it('is open by default', () => {
+ expect(vm.isOpenClass).toEqual({ 'is-open': true });
+ vm.$destroy();
+ });
+
+ it('generates correct output', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(1);
+ expect(vm.$el.id).toEqual('env-test');
+ expect(vm.$el.classList.contains('is-open')).toBe(true);
+ expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/serverless/components/function_row_spec.js b/spec/javascripts/serverless/components/function_row_spec.js
new file mode 100644
index 00000000000..6933a8f6c87
--- /dev/null
+++ b/spec/javascripts/serverless/components/function_row_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+
+import functionRowComponent from '~/serverless/components/function_row.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockServerlessFunction } from '../mock_data';
+
+const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
+
+describe('functionRowComponent', () => {
+ it('Parses the function details correctly', () => {
+ const vm = createComponent(mockServerlessFunction);
+
+ expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
+ expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
+ expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
+ expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
+ mockServerlessFunction.url,
+ );
+
+ vm.$destroy();
+ });
+
+ it('handles clicks correctly', () => {
+ const vm = createComponent(mockServerlessFunction);
+
+ expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
+ expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
+ expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/components/functions_spec.js b/spec/javascripts/serverless/components/functions_spec.js
new file mode 100644
index 00000000000..85cfe71281f
--- /dev/null
+++ b/spec/javascripts/serverless/components/functions_spec.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+
+import functionsComponent from '~/serverless/components/functions.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import ServerlessStore from '~/serverless/stores/serverless_store';
+
+import { mockServerlessFunctions } from '../mock_data';
+
+const createComponent = (
+ functions,
+ installed = true,
+ loadingData = true,
+ hasFunctionData = true,
+) => {
+ const component = Vue.extend(functionsComponent);
+
+ return mountComponent(component, {
+ functions,
+ installed,
+ clustersPath: '/testClusterPath',
+ helpPath: '/helpPath',
+ loadingData,
+ hasFunctionData,
+ });
+};
+
+describe('functionsComponent', () => {
+ it('should render empty state when Knative is not installed', () => {
+ const vm = createComponent({}, false);
+
+ expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
+ expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+ 'Getting started with serverless',
+ );
+
+ vm.$destroy();
+ });
+
+ it('should render a loading component', () => {
+ const vm = createComponent({});
+
+ expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
+ expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
+ });
+
+ it('should render empty state when there is no function data', () => {
+ const vm = createComponent({}, true, false, false);
+
+ expect(
+ vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
+ ).toBe(true);
+
+ expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+ 'No functions available',
+ );
+
+ vm.$destroy();
+ });
+
+ it('should render the functions list', () => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctions);
+ const vm = createComponent(store.state.functions, true, false);
+
+ expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
+ expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
+ });
+});
diff --git a/spec/javascripts/serverless/components/url_spec.js b/spec/javascripts/serverless/components/url_spec.js
new file mode 100644
index 00000000000..21a879a49bb
--- /dev/null
+++ b/spec/javascripts/serverless/components/url_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+
+import urlComponent from '~/serverless/components/url.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = uri => {
+ const component = Vue.extend(urlComponent);
+
+ return mountComponent(component, {
+ uri,
+ });
+};
+
+describe('urlComponent', () => {
+ it('should render correctly', () => {
+ const uri = 'http://testfunc.apps.example.com';
+ const vm = createComponent(uri);
+
+ expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
+ expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
+ uri,
+ );
+
+ expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/mock_data.js b/spec/javascripts/serverless/mock_data.js
new file mode 100644
index 00000000000..ecd393b174c
--- /dev/null
+++ b/spec/javascripts/serverless/mock_data.js
@@ -0,0 +1,79 @@
+export const mockServerlessFunctions = [
+ {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+ },
+ {
+ name: 'testfunc2',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc2.tm-example.apps.example.com',
+ description: 'A second test service\nThis one with additional descriptions',
+ image: 'knative-test-echo-buildtemplate',
+ },
+];
+
+export const mockServerlessFunctionsDiffEnv = [
+ {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+ },
+ {
+ name: 'testfunc2',
+ namespace: 'tm-example',
+ environment_scope: 'test',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc2.tm-example.apps.example.com',
+ description: 'A second test service\nThis one with additional descriptions',
+ image: 'knative-test-echo-buildtemplate',
+ },
+];
+
+export const mockServerlessFunction = {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: '3',
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+};
+
+export const mockMultilineServerlessFunction = {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: '3',
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'testfunc1\nA test service line\\nWith additional services',
+ image: 'knative-test-container-buildtemplate',
+};
diff --git a/spec/javascripts/serverless/stores/serverless_store_spec.js b/spec/javascripts/serverless/stores/serverless_store_spec.js
new file mode 100644
index 00000000000..72fd903d7d1
--- /dev/null
+++ b/spec/javascripts/serverless/stores/serverless_store_spec.js
@@ -0,0 +1,36 @@
+import ServerlessStore from '~/serverless/stores/serverless_store';
+import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+
+describe('Serverless Functions Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ });
+
+ describe('#updateFunctionsFromServer', () => {
+ it('should pass an empty hash object', () => {
+ store.updateFunctionsFromServer();
+
+ expect(store.state.functions).toEqual({});
+ });
+
+ it('should group functions to one global environment', () => {
+ const mockServerlessData = mockServerlessFunctions;
+ store.updateFunctionsFromServer(mockServerlessData);
+
+ expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
+ expect(store.state.functions['*'].length).toEqual(2);
+ });
+
+ it('should group functions to multiple environments', () => {
+ const mockServerlessData = mockServerlessFunctionsDiffEnv;
+ store.updateFunctionsFromServer(mockServerlessData);
+
+ expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
+ expect(store.state.functions['*'].length).toEqual(1);
+ expect(store.state.functions.test.length).toEqual(1);
+ expect(store.state.functions.test[0].name).toEqual('testfunc2');
+ });
+ });
+});
diff --git a/spec/javascripts/task_list_spec.js b/spec/javascripts/task_list_spec.js
new file mode 100644
index 00000000000..563f402de58
--- /dev/null
+++ b/spec/javascripts/task_list_spec.js
@@ -0,0 +1,156 @@
+import $ from 'jquery';
+import TaskList from '~/task_list';
+import axios from '~/lib/utils/axios_utils';
+
+describe('TaskList', () => {
+ let taskList;
+ let currentTarget;
+ const taskListOptions = {
+ selector: '.task-list',
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: 2,
+ };
+ const createTaskList = () => new TaskList(taskListOptions);
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="task-list">
+ <div class="js-task-list-container"></div>
+ </div>
+ `);
+
+ currentTarget = $('<div></div>');
+ taskList = createTaskList();
+ });
+
+ it('should call init when the class constructed', () => {
+ spyOn(TaskList.prototype, 'init').and.callThrough();
+ spyOn(TaskList.prototype, 'disable');
+ spyOn($.prototype, 'taskList');
+ spyOn($.prototype, 'on');
+
+ taskList = createTaskList();
+ const $taskListEl = $(taskList.taskListContainerSelector);
+
+ expect(taskList.init).toHaveBeenCalled();
+ expect(taskList.disable).toHaveBeenCalled();
+ expect($taskListEl.taskList).toHaveBeenCalledWith('enable');
+ expect($(document).on).toHaveBeenCalledWith(
+ 'tasklist:changed',
+ taskList.taskListContainerSelector,
+ taskList.updateHandler,
+ );
+ });
+
+ describe('getTaskListTarget', () => {
+ it('should return currentTarget from event object if exists', () => {
+ const $target = taskList.getTaskListTarget({ currentTarget });
+
+ expect($target).toEqual(currentTarget);
+ });
+
+ it('should return element of the taskListContainerSelector', () => {
+ const $target = taskList.getTaskListTarget();
+
+ expect($target).toEqual($(taskList.taskListContainerSelector));
+ });
+ });
+
+ describe('disableTaskListItems', () => {
+ it('should call taskList method with disable param', () => {
+ spyOn($.prototype, 'taskList');
+
+ taskList.disableTaskListItems({ currentTarget });
+
+ expect(currentTarget.taskList).toHaveBeenCalledWith('disable');
+ });
+ });
+
+ describe('enableTaskListItems', () => {
+ it('should call taskList method with enable param', () => {
+ spyOn($.prototype, 'taskList');
+
+ taskList.enableTaskListItems({ currentTarget });
+
+ expect(currentTarget.taskList).toHaveBeenCalledWith('enable');
+ });
+ });
+
+ describe('disable', () => {
+ it('should disable task list items and off document event', () => {
+ spyOn(taskList, 'disableTaskListItems');
+ spyOn($.prototype, 'off');
+
+ taskList.disable();
+
+ expect(taskList.disableTaskListItems).toHaveBeenCalled();
+ expect($(document).off).toHaveBeenCalledWith(
+ 'tasklist:changed',
+ taskList.taskListContainerSelector,
+ );
+ });
+ });
+
+ describe('update', () => {
+ it('should disable task list items and make a patch request then enable them again', done => {
+ const response = { data: { lock_version: 3 } };
+ spyOn(taskList, 'enableTaskListItems');
+ spyOn(taskList, 'disableTaskListItems');
+ spyOn(taskList, 'onSuccess');
+ spyOn(axios, 'patch').and.returnValue(Promise.resolve(response));
+
+ const value = 'hello world';
+ const endpoint = '/foo';
+ const target = $(`<input data-update-url="${endpoint}" value="${value}" />`);
+ const detail = {
+ index: 2,
+ checked: true,
+ lineNumber: 8,
+ lineSource: '- [ ] check item',
+ };
+ const event = { target, detail };
+ const patchData = {
+ [taskListOptions.dataType]: {
+ [taskListOptions.fieldName]: value,
+ lock_version: taskListOptions.lockVersion,
+ update_task: {
+ index: detail.index,
+ checked: detail.checked,
+ line_number: detail.lineNumber,
+ line_source: detail.lineSource,
+ },
+ },
+ };
+
+ taskList
+ .update(event)
+ .then(() => {
+ expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event);
+ expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData);
+ expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
+ expect(taskList.onSuccess).toHaveBeenCalledWith(response.data);
+ expect(taskList.lockVersion).toEqual(response.data.lock_version);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ it('should handle request error and enable task list items', done => {
+ const response = { data: { error: 1 } };
+ spyOn(taskList, 'enableTaskListItems');
+ spyOn(taskList, 'onError');
+ spyOn(axios, 'patch').and.returnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors
+
+ const event = { detail: {} };
+ taskList
+ .update(event)
+ .then(() => {
+ expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
+ expect(taskList.onError).toHaveBeenCalledWith(response.data);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/ide/components/file_finder/index_spec.js b/spec/javascripts/vue_shared/components/file_finder/index_spec.js
index 15ef8c31f91..bae4741f652 100644
--- a/spec/javascripts/ide/components/file_finder/index_spec.js
+++ b/spec/javascripts/vue_shared/components/file_finder/index_spec.js
@@ -1,54 +1,51 @@
import Vue from 'vue';
-import store from '~/ide/stores';
-import FindFileComponent from '~/ide/components/file_finder/index.vue';
+import Mousetrap from 'mousetrap';
+import FindFileComponent from '~/vue_shared/components/file_finder/index.vue';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
-import router from '~/ide/ide_router';
-import { file, resetStore } from '../../helpers';
-import { mountComponentWithStore } from '../../../helpers/vue_mount_component_helper';
+import { file } from 'spec/ide/helpers';
+import timeoutPromise from 'spec/helpers/set_timeout_promise_helper';
-describe('IDE File finder item spec', () => {
+describe('File finder item spec', () => {
const Component = Vue.extend(FindFileComponent);
let vm;
- beforeEach(done => {
- setFixtures('<div id="app"></div>');
-
- vm = mountComponentWithStore(Component, {
- store,
- el: '#app',
- props: {
- index: 0,
+ function createComponent(props) {
+ vm = new Component({
+ propsData: {
+ files: [],
+ visible: true,
+ loading: false,
+ ...props,
},
});
- setTimeout(done);
+ vm.$mount('#app');
+ }
+
+ beforeEach(() => {
+ setFixtures('<div id="app"></div>');
});
afterEach(() => {
vm.$destroy();
-
- resetStore(vm.$store);
});
describe('with entries', () => {
beforeEach(done => {
- Vue.set(vm.$store.state.entries, 'folder', {
- ...file('folder'),
- path: 'folder',
- type: 'folder',
- });
-
- Vue.set(vm.$store.state.entries, 'index.js', {
- ...file('index.js'),
- path: 'index.js',
- type: 'blob',
- url: '/index.jsurl',
- });
-
- Vue.set(vm.$store.state.entries, 'component.js', {
- ...file('component.js'),
- path: 'component.js',
- type: 'blob',
+ createComponent({
+ files: [
+ {
+ ...file('index.js'),
+ path: 'index.js',
+ type: 'blob',
+ url: '/index.jsurl',
+ },
+ {
+ ...file('component.js'),
+ path: 'component.js',
+ type: 'blob',
+ },
+ ],
});
setTimeout(done);
@@ -56,13 +53,14 @@ describe('IDE File finder item spec', () => {
it('renders list of blobs', () => {
expect(vm.$el.textContent).toContain('index.js');
+ expect(vm.$el.textContent).toContain('component.js');
expect(vm.$el.textContent).not.toContain('folder');
});
it('filters entries', done => {
vm.searchText = 'index';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.$el.textContent).toContain('index.js');
expect(vm.$el.textContent).not.toContain('component.js');
@@ -73,8 +71,8 @@ describe('IDE File finder item spec', () => {
it('shows clear button when searchText is not empty', done => {
vm.searchText = 'index';
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.dropdown-input-clear').classList).toContain('show');
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.dropdown-input').classList).toContain('has-value');
expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden');
done();
@@ -84,11 +82,11 @@ describe('IDE File finder item spec', () => {
it('clear button resets searchText', done => {
vm.searchText = 'index';
- vm.$nextTick()
+ timeoutPromise()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
- .then(vm.$nextTick)
+ .then(timeoutPromise)
.then(() => {
expect(vm.searchText).toBe('');
})
@@ -100,11 +98,11 @@ describe('IDE File finder item spec', () => {
spyOn(vm.$refs.searchInput, 'focus');
vm.searchText = 'index';
- vm.$nextTick()
+ timeoutPromise()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
- .then(vm.$nextTick)
+ .then(timeoutPromise)
.then(() => {
expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
})
@@ -116,7 +114,7 @@ describe('IDE File finder item spec', () => {
it('returns 1 when no filtered entries exist', done => {
vm.searchText = 'testing 123';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.listShowCount).toBe(1);
done();
@@ -136,7 +134,7 @@ describe('IDE File finder item spec', () => {
it('returns 33 when entries dont exist', done => {
vm.searchText = 'testing 123';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.listHeight).toBe(33);
done();
@@ -148,7 +146,7 @@ describe('IDE File finder item spec', () => {
it('returns length of filtered blobs', done => {
vm.searchText = 'index';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.filteredBlobsLength).toBe(1);
done();
@@ -162,7 +160,7 @@ describe('IDE File finder item spec', () => {
vm.focusedIndex = 1;
vm.searchText = 'test';
- vm.$nextTick(() => {
+ setTimeout(() => {
expect(vm.focusedIndex).toBe(0);
done();
@@ -170,16 +168,16 @@ describe('IDE File finder item spec', () => {
});
});
- describe('fileFindVisible', () => {
+ describe('visible', () => {
it('returns searchText when false', done => {
vm.searchText = 'test';
- vm.$store.state.fileFindVisible = true;
+ vm.visible = true;
- vm.$nextTick()
+ timeoutPromise()
.then(() => {
- vm.$store.state.fileFindVisible = false;
+ vm.visible = false;
})
- .then(vm.$nextTick)
+ .then(timeoutPromise)
.then(() => {
expect(vm.searchText).toBe('');
})
@@ -191,20 +189,19 @@ describe('IDE File finder item spec', () => {
describe('openFile', () => {
beforeEach(() => {
- spyOn(router, 'push');
- spyOn(vm, 'toggleFileFinder');
+ spyOn(vm, '$emit');
});
it('closes file finder', () => {
- vm.openFile(vm.$store.state.entries['index.js']);
+ vm.openFile(vm.files[0]);
- expect(vm.toggleFileFinder).toHaveBeenCalled();
+ expect(vm.$emit).toHaveBeenCalledWith('toggle', false);
});
it('pushes to router', () => {
- vm.openFile(vm.$store.state.entries['index.js']);
+ vm.openFile(vm.files[0]);
- expect(router.push).toHaveBeenCalledWith('/project/index.jsurl');
+ expect(vm.$emit).toHaveBeenCalledWith('click', vm.files[0]);
});
});
@@ -217,8 +214,8 @@ describe('IDE File finder item spec', () => {
vm.$refs.searchInput.dispatchEvent(event);
- vm.$nextTick(() => {
- expect(vm.openFile).toHaveBeenCalledWith(vm.$store.state.entries['index.js']);
+ setTimeout(() => {
+ expect(vm.openFile).toHaveBeenCalledWith(vm.files[0]);
done();
});
@@ -228,12 +225,12 @@ describe('IDE File finder item spec', () => {
const event = new CustomEvent('keyup');
event.keyCode = ESC_KEY_CODE;
- spyOn(vm, 'toggleFileFinder');
+ spyOn(vm, '$emit');
vm.$refs.searchInput.dispatchEvent(event);
- vm.$nextTick(() => {
- expect(vm.toggleFileFinder).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(vm.$emit).toHaveBeenCalledWith('toggle', false);
done();
});
@@ -287,18 +284,85 @@ describe('IDE File finder item spec', () => {
});
describe('without entries', () => {
- it('renders loading text when loading', done => {
- store.state.loading = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.textContent).toContain('Loading...');
-
- done();
+ it('renders loading text when loading', () => {
+ createComponent({
+ loading: true,
});
+
+ expect(vm.$el.textContent).toContain('Loading...');
});
it('renders no files text', () => {
+ createComponent();
+
expect(vm.$el.textContent).toContain('No files found.');
});
});
+
+ describe('keyboard shortcuts', () => {
+ beforeEach(done => {
+ createComponent();
+
+ spyOn(vm, 'toggle');
+
+ vm.$nextTick(done);
+ });
+
+ it('calls toggle on `t` key press', done => {
+ Mousetrap.trigger('t');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.toggle).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls toggle on `command+p` key press', done => {
+ Mousetrap.trigger('command+p');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.toggle).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls toggle on `ctrl+p` key press', done => {
+ Mousetrap.trigger('ctrl+p');
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.toggle).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('always allows `command+p` to trigger toggle', () => {
+ expect(
+ vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
+ ).toBe(false);
+ });
+
+ it('always allows `ctrl+p` to trigger toggle', () => {
+ expect(
+ vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
+ ).toBe(false);
+ });
+
+ it('onlys handles `t` when focused in input-field', () => {
+ expect(
+ vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
+ ).toBe(true);
+ });
+
+ it('stops callback in monaco editor', () => {
+ setFixtures('<div class="inputarea"></div>');
+
+ expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true);
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/file_finder/item_spec.js b/spec/javascripts/vue_shared/components/file_finder/item_spec.js
index 0f1116c6912..c1511643a9d 100644
--- a/spec/javascripts/ide/components/file_finder/item_spec.js
+++ b/spec/javascripts/vue_shared/components/file_finder/item_spec.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
-import ItemComponent from '~/ide/components/file_finder/item.vue';
-import { file } from '../../helpers';
+import ItemComponent from '~/vue_shared/components/file_finder/item.vue';
+import { file } from 'spec/ide/helpers';
import createComponent from '../../../helpers/vue_mount_component_helper';
-describe('IDE File finder item spec', () => {
+describe('File finder item spec', () => {
const Component = Vue.extend(ItemComponent);
let vm;
let localFile;
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index e1aea82653d..08165f147bb 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -179,7 +179,7 @@ describe API::Helpers do
context 'when blob name is not null' do
it 'returns disposition with the blob name' do
- expect(send_git_blob['Content-Disposition']).to eq 'inline; filename="foobar"'
+ expect(send_git_blob['Content-Disposition']).to eq %q(inline; filename="foobar"; filename*=UTF-8''foobar)
end
end
end
diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb
index 6217381c491..4972c4b4bd2 100644
--- a/spec/lib/banzai/filter/autolink_filter_spec.rb
+++ b/spec/lib/banzai/filter/autolink_filter_spec.rb
@@ -121,6 +121,13 @@ describe Banzai::Filter::AutolinkFilter do
expect(doc.to_s).to eq("See #{link}")
end
+ it 'does not autolink bad URLs after we remove trailing punctuation' do
+ link = 'http://]'
+ doc = filter("See #{link}")
+
+ expect(doc.to_s).to eq("See #{link}")
+ end
+
it 'does not include trailing punctuation' do
['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation|
doc = filter("See #{link}#{trailing_punctuation}")
diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb
index 4c4e821deab..83fcda29680 100644
--- a/spec/lib/banzai/filter/markdown_filter_spec.rb
+++ b/spec/lib/banzai/filter/markdown_filter_spec.rb
@@ -10,12 +10,6 @@ describe Banzai::Filter::MarkdownFilter do
filter('test')
end
- it 'uses Redcarpet' do
- expect_any_instance_of(Banzai::Filter::MarkdownEngines::Redcarpet).to receive(:render).and_return('test')
-
- filter('test', { markdown_engine: :redcarpet })
- end
-
it 'uses CommonMark' do
expect_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark).to receive(:render).and_return('test')
@@ -47,24 +41,6 @@ describe Banzai::Filter::MarkdownFilter do
expect(result).to start_with('<pre><code lang="æ—¥">')
end
end
-
- context 'using Redcarpet' do
- before do
- stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :redcarpet)
- end
-
- it 'adds language to lang attribute when specified' do
- result = filter("```html\nsome code\n```")
-
- expect(result).to start_with("\n<pre><code lang=\"html\">")
- end
-
- it 'does not add language to lang attribute when not specified' do
- result = filter("```\nsome code\n```")
-
- expect(result).to start_with("\n<pre><code>")
- end
- end
end
describe 'source line position' do
@@ -85,18 +61,6 @@ describe Banzai::Filter::MarkdownFilter do
expect(result).to eq '<p>test</p>'
end
end
-
- context 'using Redcarpet' do
- before do
- stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :redcarpet)
- end
-
- it 'does not support data-sourcepos' do
- result = filter('test')
-
- expect(result).to eq '<p>test</p>'
- end
- end
end
describe 'footnotes in tables' do
diff --git a/spec/lib/banzai/filter/spaced_link_filter_spec.rb b/spec/lib/banzai/filter/spaced_link_filter_spec.rb
index 1ad7f3ff567..76d7644d76c 100644
--- a/spec/lib/banzai/filter/spaced_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/spaced_link_filter_spec.rb
@@ -26,11 +26,6 @@ describe Banzai::Filter::SpacedLinkFilter do
expect(doc.at_css('p')).to be_nil
end
- it 'does nothing when markdown_engine is redcarpet' do
- exp = act = link
- expect(filter(act, markdown_engine: :redcarpet).to_html).to eq exp
- end
-
it 'does nothing with empty text' do
link = '[](page slug)'
doc = filter("See #{link}")
diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb
index 8a83b76fd94..7d3d8a949ef 100644
--- a/spec/lib/gitlab/background_migration_spec.rb
+++ b/spec/lib/gitlab/background_migration_spec.rb
@@ -104,6 +104,38 @@ describe Gitlab::BackgroundMigration do
end
end
end
+
+ context 'when retry_dead_jobs is true', :sidekiq, :redis do
+ let(:retry_queue) do
+ [double(args: ['Object', [3]], queue: described_class.queue, delete: true)]
+ end
+ let(:dead_queue) do
+ [double(args: ['Object', [4]], queue: described_class.queue, delete: true)]
+ end
+
+ before do
+ allow(Sidekiq::RetrySet).to receive(:new).and_return(retry_queue)
+ allow(Sidekiq::DeadSet).to receive(:new).and_return(dead_queue)
+ end
+
+ it 'steals from the dead and retry queue' do
+ Sidekiq::Testing.disable! do
+ expect(described_class).to receive(:perform)
+ .with('Object', [1]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [2]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [3]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [4]).ordered
+
+ BackgroundMigrationWorker.perform_async('Object', [2])
+ BackgroundMigrationWorker.perform_in(10.minutes, 'Object', [1])
+
+ described_class.steal('Object', retry_dead_jobs: true)
+ end
+ end
+ end
end
describe '.perform' do
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index e5420ea6bea..50e473c459e 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -155,11 +155,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
it_behaves_like "checks permissions on noteable"
end
- context "when everything is fine" do
- before do
- setup_attachment
- end
-
+ shared_examples 'a reply to existing comment' do
it "creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
new_note = noteable.notes.last
@@ -168,7 +164,21 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
expect(new_note.position).to eq(note.position)
expect(new_note.note).to include("I could not disagree more.")
expect(new_note.in_reply_to?(note)).to be_truthy
+
+ if note.part_of_discussion?
+ expect(new_note.discussion_id).to eq(note.discussion_id)
+ else
+ expect(new_note.discussion_id).not_to eq(note.discussion_id)
+ end
end
+ end
+
+ context "when everything is fine" do
+ before do
+ setup_attachment
+ end
+
+ it_behaves_like 'a reply to existing comment'
it "adds all attachments" do
receiver.execute
@@ -207,4 +217,10 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
end
end
end
+
+ context "when note is not a discussion" do
+ let(:note) { create(:note_on_merge_request, project: project) }
+
+ it_behaves_like 'a reply to existing comment'
+ end
end
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index fe2087e8fc3..baca8f6d542 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -603,5 +603,6 @@ ResourceLabelEvent:
ErrorTracking::ProjectErrorTrackingSetting:
- id
- api_url
-- enabled
- project_id
+- project_name
+- organization_name
diff --git a/spec/migrations/fix_null_type_labels_spec.rb b/spec/migrations/fix_null_type_labels_spec.rb
new file mode 100644
index 00000000000..462ae9b913f
--- /dev/null
+++ b/spec/migrations/fix_null_type_labels_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20190131122559_fix_null_type_labels')
+
+describe FixNullTypeLabels, :migration do
+ let(:migration) { described_class.new }
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:labels) { table(:labels) }
+
+ before do
+ group = namespaces.create(name: 'labels-test-project', path: 'labels-test-project', type: 'Group')
+ project = projects.create!(namespace_id: group.id, name: 'labels-test-group', path: 'labels-test-group')
+
+ @template_label = labels.create(title: 'template', template: true)
+ @project_label = labels.create(title: 'project label', project_id: project.id, type: 'ProjectLabel')
+ @group_label = labels.create(title: 'group_label', group_id: group.id, type: 'GroupLabel')
+ @broken_label_1 = labels.create(title: 'broken 1', project_id: project.id)
+ @broken_label_2 = labels.create(title: 'broken 2', project_id: project.id)
+ end
+
+ describe '#up' do
+ it 'fix labels with type missing' do
+ migration.up
+
+ # Labels that requires type change
+ expect(@broken_label_1.reload.type).to eq('ProjectLabel')
+ expect(@broken_label_2.reload.type).to eq('ProjectLabel')
+ # Labels out of scope
+ expect(@template_label.reload.type).to be_nil
+ expect(@project_label.reload.type).to eq('ProjectLabel')
+ expect(@group_label.reload.type).to eq('GroupLabel')
+ end
+ end
+end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index 68aed387bfc..ca23f581fdc 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -10,4 +10,14 @@ describe ApplicationRecord do
expect(User.id_in(records.last(2).map(&:id))).to eq(records.last(2))
end
end
+
+ describe '#safe_find_or_create_by' do
+ it 'creates the user avoiding race conditions' do
+ expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique)
+ allow(Suggestion).to receive(:find_or_create_by).and_call_original
+
+ expect { Suggestion.safe_find_or_create_by(build(:suggestion).attributes) }
+ .to change { Suggestion.count }.by(1)
+ end
+ end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 8a1bbb26e57..47865e4d08f 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1844,6 +1844,26 @@ describe Ci::Build do
context 'when there is no environment' do
it { is_expected.to be_nil }
end
+
+ context 'when build has a start environment' do
+ let(:build) { create(:ci_build, :deploy_to_production, pipeline: pipeline) }
+
+ it 'does not expand environment name' do
+ expect(build).not_to receive(:expanded_environment_name)
+
+ subject
+ end
+ end
+
+ context 'when build has a stop environment' do
+ let(:build) { create(:ci_build, :stop_review_app, pipeline: pipeline) }
+
+ it 'expands environment name' do
+ expect(build).to receive(:expanded_environment_name)
+
+ subject
+ end
+ end
end
describe '#play' do
diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb
index ef6af232999..29197ef372e 100644
--- a/spec/models/concerns/cache_markdown_field_spec.rb
+++ b/spec/models/concerns/cache_markdown_field_spec.rb
@@ -73,6 +73,7 @@ describe CacheMarkdownField do
let(:updated_html) { '<p dir="auto"><code>Bar</code></p>' }
let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) }
+ let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION }
before do
stub_commonmark_sourcepos_disabled
@@ -97,20 +98,15 @@ describe CacheMarkdownField do
end
context 'a changed markdown field' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
- before do
- thing.foo = updated_markdown
- thing.save
- end
-
- it { expect(thing.foo_html).to eq(updated_html) }
- it { expect(thing.cached_markdown_version).to eq(cache_version) }
+ before do
+ thing.foo = updated_markdown
+ thing.save
end
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ it { expect(thing.foo_html).to eq(updated_html) }
+ it { expect(thing.cached_markdown_version).to eq(cache_version) }
end
context 'when a markdown field is set repeatedly to an empty string' do
@@ -133,23 +129,27 @@ describe CacheMarkdownField do
end
end
- context 'a non-markdown field changed' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ context 'when a markdown field and html field are both changed' do
+ it do
+ expect(thing).not_to receive(:refresh_markdown_cache)
+ thing.foo = '_look over there!_'
+ thing.foo_html = '<em>look over there!</em>'
+ thing.save
+ end
+ end
- before do
- thing.bar = 'OK'
- thing.save
- end
+ context 'a non-markdown field changed' do
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
- it { expect(thing.bar).to eq('OK') }
- it { expect(thing.foo).to eq(markdown) }
- it { expect(thing.foo_html).to eq(html) }
- it { expect(thing.cached_markdown_version).to eq(cache_version) }
+ before do
+ thing.bar = 'OK'
+ thing.save
end
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ it { expect(thing.bar).to eq('OK') }
+ it { expect(thing.foo).to eq(markdown) }
+ it { expect(thing.foo_html).to eq(html) }
+ it { expect(thing.cached_markdown_version).to eq(cache_version) }
end
context 'version is out of date' do
@@ -164,73 +164,63 @@ describe CacheMarkdownField do
end
describe '#cached_html_up_to_date?' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
- subject { thing.cached_html_up_to_date?(:foo) }
+ subject { thing.cached_html_up_to_date?(:foo) }
- it 'returns false when the version is absent' do
- thing.cached_markdown_version = nil
+ it 'returns false when the version is absent' do
+ thing.cached_markdown_version = nil
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns false when the version is too early' do
- thing.cached_markdown_version -= 1
+ it 'returns false when the version is too early' do
+ thing.cached_markdown_version -= 1
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns false when the version is too late' do
- thing.cached_markdown_version += 1
+ it 'returns false when the version is too late' do
+ thing.cached_markdown_version += 1
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns true when the version is just right' do
- thing.cached_markdown_version = cache_version
+ it 'returns true when the version is just right' do
+ thing.cached_markdown_version = cache_version
- is_expected.to be_truthy
- end
+ is_expected.to be_truthy
+ end
- it 'returns false if markdown has been changed but html has not' do
- thing.foo = updated_html
+ it 'returns false if markdown has been changed but html has not' do
+ thing.foo = updated_html
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
+ end
- it 'returns true if markdown has not been changed but html has' do
- thing.foo_html = updated_html
+ it 'returns true if markdown has not been changed but html has' do
+ thing.foo_html = updated_html
- is_expected.to be_truthy
- end
+ is_expected.to be_truthy
+ end
- it 'returns true if markdown and html have both been changed' do
- thing.foo = updated_markdown
- thing.foo_html = updated_html
+ it 'returns true if markdown and html have both been changed' do
+ thing.foo = updated_markdown
+ thing.foo_html = updated_html
- is_expected.to be_truthy
- end
+ is_expected.to be_truthy
+ end
- it 'returns false if the markdown field is set but the html is not' do
- thing.foo_html = nil
+ it 'returns false if the markdown field is set but the html is not' do
+ thing.foo_html = nil
- is_expected.to be_falsy
- end
+ is_expected.to be_falsy
end
-
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
end
describe '#latest_cached_markdown_version' do
subject { thing.latest_cached_markdown_version }
- it 'returns redcarpet version' do
- thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1
- is_expected.to eq(CacheMarkdownField::CACHE_REDCARPET_VERSION)
- end
-
it 'returns commonmark version' do
thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + 1
is_expected.to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION)
@@ -270,39 +260,34 @@ describe CacheMarkdownField do
end
describe '#refresh_markdown_cache!' do
- shared_examples 'with cache version' do |cache_version|
- let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
+ let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
- before do
- thing.foo = updated_markdown
- end
+ before do
+ thing.foo = updated_markdown
+ end
- it 'fills all html fields' do
- thing.refresh_markdown_cache!
+ it 'fills all html fields' do
+ thing.refresh_markdown_cache!
- expect(thing.foo_html).to eq(updated_html)
- expect(thing.foo_html_changed?).to be_truthy
- expect(thing.baz_html_changed?).to be_truthy
- end
+ expect(thing.foo_html).to eq(updated_html)
+ expect(thing.foo_html_changed?).to be_truthy
+ expect(thing.baz_html_changed?).to be_truthy
+ end
- it 'skips saving if not persisted' do
- expect(thing).to receive(:persisted?).and_return(false)
- expect(thing).not_to receive(:update_columns)
+ it 'skips saving if not persisted' do
+ expect(thing).to receive(:persisted?).and_return(false)
+ expect(thing).not_to receive(:update_columns)
- thing.refresh_markdown_cache!
- end
+ thing.refresh_markdown_cache!
+ end
- it 'saves the changes using #update_columns' do
- expect(thing).to receive(:persisted?).and_return(true)
- expect(thing).to receive(:update_columns)
- .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => cache_version)
+ it 'saves the changes using #update_columns' do
+ expect(thing).to receive(:persisted?).and_return(true)
+ expect(thing).to receive(:update_columns)
+ .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => cache_version)
- thing.refresh_markdown_cache!
- end
+ thing.refresh_markdown_cache!
end
-
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION
- it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION
end
describe '#banzai_render_context' do
@@ -375,20 +360,4 @@ describe CacheMarkdownField do
end
end
end
-
- describe CacheMarkdownField::MarkdownEngine do
- subject { lambda { |version| CacheMarkdownField::MarkdownEngine.from_version(version) } }
-
- it 'returns :common_mark as a default' do
- expect(subject.call(nil)).to eq :common_mark
- end
-
- it 'returns :common_mark' do
- expect(subject.call(CacheMarkdownField::CACHE_COMMONMARK_VERSION)).to eq :common_mark
- end
-
- it 'returns :redcarpet' do
- expect(subject.call(CacheMarkdownField::CACHE_REDCARPET_VERSION)).to eq :redcarpet
- end
- end
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 5753c646106..41159348e04 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -139,6 +139,78 @@ describe Issuable do
it 'returns issues with a matching description for a query shorter than 3 chars' do
expect(issuable_class.full_search(searchable_issue2.description.downcase)).to eq([searchable_issue2])
end
+
+ context 'when matching columns is "title"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns no issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title'))
+ .to be_empty
+ end
+ end
+
+ context 'when matching columns is "description"' do
+ it 'returns no issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'description'))
+ .to be_empty
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'description'))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is "title,description"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,description'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,description'))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is nil"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: nil))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: nil))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is "invalid"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'invalid'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'invalid'))
+ .to eq([searchable_issue])
+ end
+ end
+
+ context 'when matching columns is "title,invalid"' do
+ it 'returns issues with a matching title' do
+ expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,invalid'))
+ .to eq([searchable_issue])
+ end
+
+ it 'returns no issues with a matching description' do
+ expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,invalid'))
+ .to be_empty
+ end
+ end
end
describe '.to_ability_name' do
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 9a3f1f1c5a1..2d554326f05 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -41,6 +41,76 @@ describe Environment do
end
end
+ describe '.for_name_like' do
+ subject { project.environments.for_name_like(query, limit: limit) }
+
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+ let(:query) { 'pro' }
+ let(:limit) { 5 }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+
+ context 'when query is production' do
+ let(:query) { 'production' }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+ end
+
+ context 'when query is productionA' do
+ let(:query) { 'productionA' }
+
+ it 'returns empty array' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when query is empty' do
+ let(:query) { '' }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+ end
+
+ context 'when query is nil' do
+ let(:query) { }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(NoMethodError)
+ end
+ end
+
+ context 'when query is partially matched in the middle of environment name' do
+ let(:query) { 'duction' }
+
+ it 'returns empty array' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when query contains a wildcard character' do
+ let(:query) { 'produc%' }
+
+ it 'prevents wildcard injection' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ describe '.pluck_names' do
+ subject { described_class.pluck_names }
+
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+
+ it 'plucks names' do
+ is_expected.to eq(%w[production])
+ end
+ end
+
describe '#expire_etag_cache' do
let(:store) { Gitlab::EtagCaching::Store.new }
diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
index 2f8ab21d4b2..d30228b863c 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -15,9 +15,11 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
describe 'Validations' do
context 'when api_url is over 255 chars' do
- it 'fails validation' do
+ before do
subject.api_url = 'https://' + 'a' * 250
+ end
+ it 'fails validation' do
expect(subject).not_to be_valid
expect(subject.errors.messages[:api_url]).to include('is too long (maximum is 255 characters)')
end
@@ -31,6 +33,34 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
+ context 'presence validations' do
+ using RSpec::Parameterized::TableSyntax
+
+ valid_api_url = 'http://example.com/api/0/projects/org-slug/proj-slug/'
+ valid_token = 'token'
+
+ where(:enabled, :token, :api_url, :valid?) do
+ true | nil | nil | false
+ true | nil | valid_api_url | false
+ true | valid_token | nil | false
+ true | valid_token | valid_api_url | true
+ false | nil | nil | true
+ false | nil | valid_api_url | true
+ false | valid_token | nil | true
+ false | valid_token | valid_api_url | true
+ end
+
+ with_them do
+ before do
+ subject.enabled = enabled
+ subject.token = token
+ subject.api_url = api_url
+ end
+
+ it { expect(subject.valid?).to eq(valid?) }
+ end
+ end
+
context 'URL path' do
it 'fails validation with wrong path' do
subject.api_url = 'http://gitlab.com/project1/something'
@@ -45,6 +75,16 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
expect(subject).to be_valid
end
end
+
+ context 'non ascii chars in api_url' do
+ before do
+ subject.api_url = 'http://gitlab.com/api/0/projects/project1/something€'
+ end
+
+ it 'fails validation' do
+ expect(subject).not_to be_valid
+ end
+ end
end
describe '#sentry_external_url' do
@@ -106,4 +146,138 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
end
+
+ describe '#list_sentry_projects' do
+ let(:projects) { [:list, :of, :projects] }
+ let(:sentry_client) { spy(:sentry_client) }
+
+ it 'calls sentry client' do
+ expect(subject).to receive(:sentry_client).and_return(sentry_client)
+ expect(sentry_client).to receive(:list_projects).and_return(projects)
+
+ result = subject.list_sentry_projects
+
+ expect(result).to eq(projects: projects)
+ end
+ end
+
+ context 'slugs' do
+ shared_examples_for 'slug from api_url' do |method, slug|
+ context 'when api_url is correct' do
+ before do
+ subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug/'
+ end
+
+ it 'returns slug' do
+ expect(subject.public_send(method)).to eq(slug)
+ end
+ end
+
+ context 'when api_url is blank' do
+ before do
+ subject.api_url = nil
+ end
+
+ it 'returns nil' do
+ expect(subject.public_send(method)).to be_nil
+ end
+ end
+ end
+
+ it_behaves_like 'slug from api_url', :project_slug, 'project-slug'
+ it_behaves_like 'slug from api_url', :organization_slug, 'org-slug'
+ end
+
+ context 'names from api_url' do
+ shared_examples_for 'name from api_url' do |name, titleized_slug|
+ context 'name is present in DB' do
+ it 'returns name from DB' do
+ subject[name] = 'Sentry name'
+ subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug'
+
+ expect(subject.public_send(name)).to eq('Sentry name')
+ end
+ end
+
+ context 'name is null in DB' do
+ it 'titleizes and returns slug from api_url' do
+ subject[name] = nil
+ subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug'
+
+ expect(subject.public_send(name)).to eq(titleized_slug)
+ end
+
+ it 'returns nil when api_url is incorrect' do
+ subject[name] = nil
+ subject.api_url = 'http://gitlab.com/api/0/projects/'
+
+ expect(subject.public_send(name)).to be_nil
+ end
+
+ it 'returns nil when api_url is blank' do
+ subject[name] = nil
+ subject.api_url = nil
+
+ expect(subject.public_send(name)).to be_nil
+ end
+ end
+ end
+
+ it_behaves_like 'name from api_url', :organization_name, 'Org Slug'
+ it_behaves_like 'name from api_url', :project_name, 'Project Slug'
+ end
+
+ describe '.build_api_url_from' do
+ it 'correctly builds api_url with slugs' do
+ api_url = described_class.build_api_url_from(
+ api_host: 'http://sentry.com/',
+ organization_slug: 'org-slug',
+ project_slug: 'proj-slug'
+ )
+
+ expect(api_url).to eq('http://sentry.com/api/0/projects/org-slug/proj-slug/')
+ end
+
+ it 'correctly builds api_url without slugs' do
+ api_url = described_class.build_api_url_from(
+ api_host: 'http://sentry.com/',
+ organization_slug: nil,
+ project_slug: nil
+ )
+
+ expect(api_url).to eq('http://sentry.com/api/0/projects/')
+ end
+
+ it 'does not raise exception with invalid url' do
+ api_url = described_class.build_api_url_from(
+ api_host: ':::',
+ organization_slug: 'org-slug',
+ project_slug: 'proj-slug'
+ )
+
+ expect(api_url).to eq(':::')
+ end
+ end
+
+ describe '#api_host' do
+ context 'when api_url exists' do
+ before do
+ subject.api_url = 'https://example.com/api/0/projects/org-slug/proj-slug/'
+ end
+
+ it 'extracts the api_host from api_url' do
+ expect(subject.api_host).to eq('https://example.com/')
+ end
+ end
+
+ context 'when api_url is nil' do
+ before do
+ subject.api_url = nil
+ end
+
+ it 'returns nil' do
+ expect(subject.api_url).to eq(nil)
+ end
+ end
+ end
end
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 33e984dc399..1849d3bac12 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -46,7 +46,7 @@ describe MergeRequestDiff do
it { expect(first_diff.reload).not_to be_latest }
end
- describe '#diffs' do
+ shared_examples_for 'merge request diffs' do
let(:merge_request) { create(:merge_request, :with_diffs) }
let!(:diff) { merge_request.merge_request_diff.reload }
@@ -91,98 +91,110 @@ describe MergeRequestDiff do
diff.diffs.diff_files
end
end
- end
- describe '#raw_diffs' do
- context 'when the :ignore_whitespace_change option is set' do
- it 'creates a new compare object instead of loading from the DB' do
- expect(diff_with_commits).not_to receive(:load_diffs)
- expect(diff_with_commits.compare).to receive(:diffs).and_call_original
+ describe '#raw_diffs' do
+ context 'when the :ignore_whitespace_change option is set' do
+ it 'creates a new compare object instead of using preprocessed data' do
+ expect(diff_with_commits).not_to receive(:load_diffs)
+ expect(diff_with_commits.compare).to receive(:diffs).and_call_original
- diff_with_commits.raw_diffs(ignore_whitespace_change: true)
+ diff_with_commits.raw_diffs(ignore_whitespace_change: true)
+ end
end
- end
- context 'when the raw diffs are empty' do
- before do
- MergeRequestDiffFile.where(merge_request_diff_id: diff_with_commits.id).delete_all
- end
+ context 'when the raw diffs are empty' do
+ before do
+ MergeRequestDiffFile.where(merge_request_diff_id: diff_with_commits.id).delete_all
+ end
- it 'returns an empty DiffCollection' do
- expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
- expect(diff_with_commits.raw_diffs).to be_empty
+ it 'returns an empty DiffCollection' do
+ expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
+ expect(diff_with_commits.raw_diffs).to be_empty
+ end
end
- end
- context 'when the raw diffs exist' do
- it 'returns the diffs' do
- expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
- expect(diff_with_commits.raw_diffs).not_to be_empty
- end
+ context 'when the raw diffs exist' do
+ it 'returns the diffs' do
+ expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
+ expect(diff_with_commits.raw_diffs).not_to be_empty
+ end
- context 'when the :paths option is set' do
- let(:diffs) { diff_with_commits.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) }
+ context 'when the :paths option is set' do
+ let(:diffs) { diff_with_commits.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) }
- it 'only returns diffs that match the (old path, new path) given' do
- expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb')
- end
+ it 'only returns diffs that match the (old path, new path) given' do
+ expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb')
+ end
- it 'only serializes diff files found by query' do
- expect(diff_with_commits.merge_request_diff_files.count).to be > 10
- expect_any_instance_of(MergeRequestDiffFile).to receive(:to_hash).once
+ it 'only serializes diff files found by query' do
+ expect(diff_with_commits.merge_request_diff_files.count).to be > 10
+ expect_any_instance_of(MergeRequestDiffFile).to receive(:to_hash).once
- diffs
- end
+ diffs
+ end
- it 'uses the diffs from the DB' do
- expect(diff_with_commits).to receive(:load_diffs)
+ it 'uses the preprocessed diffs' do
+ expect(diff_with_commits).to receive(:load_diffs)
- diffs
+ diffs
+ end
end
end
end
- end
- describe '#save_diffs' do
- it 'saves collected state' do
- mr_diff = create(:merge_request).merge_request_diff
+ describe '#save_diffs' do
+ it 'saves collected state' do
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.collected?).to be_truthy
- end
+ expect(mr_diff.collected?).to be_truthy
+ end
- it 'saves overflow state' do
- allow(Commit).to receive(:max_diff_options)
- .and_return(max_lines: 0, max_files: 0)
+ it 'saves overflow state' do
+ allow(Commit).to receive(:max_diff_options)
+ .and_return(max_lines: 0, max_files: 0)
- mr_diff = create(:merge_request).merge_request_diff
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.overflow?).to be_truthy
- end
+ expect(mr_diff.overflow?).to be_truthy
+ end
- it 'saves empty state' do
- allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
- .and_return([])
+ it 'saves empty state' do
+ allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
+ .and_return([])
- mr_diff = create(:merge_request).merge_request_diff
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.empty?).to be_truthy
- end
+ expect(mr_diff.empty?).to be_truthy
+ end
- it 'expands collapsed diffs before saving' do
- mr_diff = create(:merge_request, source_branch: 'expand-collapse-lines', target_branch: 'master').merge_request_diff
- diff_file = mr_diff.merge_request_diff_files.find_by(new_path: 'expand-collapse/file-5.txt')
+ it 'expands collapsed diffs before saving' do
+ mr_diff = create(:merge_request, source_branch: 'expand-collapse-lines', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: 'expand-collapse/file-5.txt')
- expect(diff_file.diff).not_to be_empty
+ expect(diff_file.diff).not_to be_empty
+ end
+
+ it 'saves binary diffs correctly' do
+ path = 'files/images/icn-time-tracking.pdf'
+ mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+
+ expect(diff_file).to be_binary
+ expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ end
end
+ end
- it 'saves binary diffs correctly' do
- path = 'files/images/icn-time-tracking.pdf'
- mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
- diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+ describe 'internal diffs configured' do
+ include_examples 'merge request diffs'
+ end
- expect(diff_file).to be_binary
- expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ describe 'external diffs configured' do
+ before do
+ stub_external_diffs_setting(enabled: true)
end
+
+ include_examples 'merge request diffs'
end
describe '#commit_shas' do
@@ -245,4 +257,55 @@ describe MergeRequestDiff do
expect(subject.modified_paths).to eq(%w{foo bar baz})
end
end
+
+ describe '#opening_external_diff' do
+ subject(:diff) { diff_with_commits }
+
+ context 'external diffs disabled' do
+ it { expect(diff.external_diff).not_to be_exists }
+
+ it 'yields nil' do
+ expect { |b| diff.opening_external_diff(&b) }.to yield_with_args(nil)
+ end
+ end
+
+ context 'external diffs enabled' do
+ let(:test_dir) { 'tmp/tests/external-diffs' }
+
+ around do |example|
+ FileUtils.mkdir_p(test_dir)
+
+ begin
+ example.run
+ ensure
+ FileUtils.rm_rf(test_dir)
+ end
+ end
+
+ before do
+ stub_external_diffs_setting(enabled: true, storage_path: test_dir)
+ end
+
+ it { expect(diff.external_diff).to be_exists }
+
+ it 'yields an open file' do
+ expect { |b| diff.opening_external_diff(&b) }.to yield_with_args(File)
+ end
+
+ it 'is re-entrant' do
+ outer_file_a =
+ diff.opening_external_diff do |outer_file|
+ diff.opening_external_diff do |inner_file|
+ expect(outer_file).to eq(inner_file)
+ end
+
+ outer_file
+ end
+
+ diff.opening_external_diff do |outer_file_b|
+ expect(outer_file_a).not_to eq(outer_file_b)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index ac5874fd0f7..4978c43c9b5 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -1237,6 +1237,27 @@ describe Repository do
end
end
+ describe '#blobs_at' do
+ let(:empty_repository) { create(:project_empty_repo).repository }
+
+ it 'returns empty array for an empty repository' do
+ # rubocop:disable Style/WordArray
+ expect(empty_repository.blobs_at(['master', 'foobar'])).to eq([])
+ # rubocop:enable Style/WordArray
+ end
+
+ it 'returns blob array for a non-empty repository' do
+ repository.create_file(User.last, 'foobar', 'CONTENT', message: 'message', branch_name: 'master')
+
+ # rubocop:disable Style/WordArray
+ blobs = repository.blobs_at([['master', 'foobar']])
+ # rubocop:enable Style/WordArray
+
+ expect(blobs.first.name).to eq('foobar')
+ expect(blobs.size).to eq(1)
+ end
+ end
+
describe '#root_ref' do
it 'returns a branch name' do
expect(repository.root_ref).to be_an_instance_of(String)
diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb
index 677613b7980..6c35ed8f649 100644
--- a/spec/models/sent_notification_spec.rb
+++ b/spec/models/sent_notification_spec.rb
@@ -36,19 +36,41 @@ describe SentNotification do
end
end
+ shared_examples 'a successful sent notification' do
+ it 'creates a new SentNotification' do
+ expect { subject }.to change { described_class.count }.by(1)
+ end
+ end
+
describe '.record' do
let(:issue) { create(:issue) }
- it 'creates a new SentNotification' do
- expect { described_class.record(issue, user.id) }.to change { described_class.count }.by(1)
- end
+ subject { described_class.record(issue, user.id) }
+
+ it_behaves_like 'a successful sent notification'
end
describe '.record_note' do
- let(:note) { create(:diff_note_on_merge_request) }
+ subject { described_class.record_note(note, note.author.id) }
- it 'creates a new SentNotification' do
- expect { described_class.record_note(note, note.author.id) }.to change { described_class.count }.by(1)
+ context 'for a discussion note' do
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it_behaves_like 'a successful sent notification'
+
+ it 'sets in_reply_to_discussion_id' do
+ expect(subject.in_reply_to_discussion_id).to eq(note.discussion_id)
+ end
+ end
+
+ context 'for an individual note' do
+ let(:note) { create(:note_on_merge_request) }
+
+ it_behaves_like 'a successful sent notification'
+
+ it 'does not set in_reply_to_discussion_id' do
+ expect(subject.in_reply_to_discussion_id).to be_nil
+ end
end
end
diff --git a/spec/models/ssh_host_key_spec.rb b/spec/models/ssh_host_key_spec.rb
index 75db43b3d56..4c677569561 100644
--- a/spec/models/ssh_host_key_spec.rb
+++ b/spec/models/ssh_host_key_spec.rb
@@ -50,6 +50,35 @@ describe SshHostKey do
subject(:ssh_host_key) { described_class.new(project: project, url: 'ssh://example.com:2222', compare_host_keys: compare_host_keys) }
+ describe '.primary_key' do
+ it 'returns a symbol' do
+ expect(described_class.primary_key).to eq(:id)
+ end
+ end
+
+ describe '.find_by' do
+ let(:project) { create(:project) }
+ let(:url) { 'ssh://invalid.invalid:2222' }
+
+ let(:finding_id) { [project.id, url].join(':') }
+
+ it 'accepts a string key' do
+ result = described_class.find_by('id' => finding_id)
+
+ expect(result).to be_a(described_class)
+ expect(result.project).to eq(project)
+ expect(result.url.to_s).to eq(url)
+ end
+
+ it 'accepts a symbol key' do
+ result = described_class.find_by(id: finding_id)
+
+ expect(result).to be_a(described_class)
+ expect(result.project).to eq(project)
+ expect(result.url.to_s).to eq(url)
+ end
+ end
+
describe '#fingerprints', :use_clean_rails_memory_store_caching do
it 'returns an array of indexed fingerprints when the cache is filled' do
stub_reactive_cache(ssh_host_key, known_hosts: known_hosts)
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index 9b32dc78274..1ad536258ba 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -191,7 +191,7 @@ describe API::Files do
get api(url, current_user), params: params
- expect(headers['Content-Disposition']).to eq('inline; filename="popen.rb"')
+ expect(headers['Content-Disposition']).to eq(%q(inline; filename="popen.rb"; filename*=UTF-8''popen.rb))
end
context 'when mandatory params are not given' do
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index e0f1e303e96..04908378a24 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -208,6 +208,18 @@ describe API::Issues do
expect_paginated_array_response(issue.id)
end
+ it 'returns issues matching given search string for title and scoped in title' do
+ get api("/issues", user), params: { search: issue.title, in: 'title' }
+
+ expect_paginated_array_response(issue.id)
+ end
+
+ it 'returns an empty array if no issue matches given search string for title and scoped in description' do
+ get api("/issues", user), params: { search: issue.title, in: 'description' }
+
+ expect_paginated_array_response([])
+ end
+
it 'returns issues matching given search string for description' do
get api("/issues", user), params: { search: issue.description }
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 97aa71bf231..3defe8bbf51 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -403,7 +403,7 @@ describe API::Jobs do
shared_examples 'downloads artifact' do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
end
it 'returns specific job artifacts' do
@@ -555,7 +555,7 @@ describe API::Jobs do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
'Content-Disposition' =>
- "attachment; filename=#{job.artifacts_file.filename}" }
+ %Q(attachment; filename="#{job.artifacts_file.filename}"; filename*=UTF-8''#{job.artifacts_file.filename}) }
end
it { expect(response).to have_http_status(:ok) }
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 4d42bc39ac3..51343287a13 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -260,6 +260,18 @@ describe API::MergeRequests do
expect_response_ordered_exactly(merge_request)
end
+ it 'returns merge requests matching given search string for title and scoped in title' do
+ get api("/merge_requests", user), params: { search: merge_request.title, in: 'title' }
+
+ expect_response_ordered_exactly(merge_request)
+ end
+
+ it 'returns an empty array if no merge reques matches given search string for description and scoped in title' do
+ get api("/merge_requests", user), params: { search: merge_request.description, in: 'title' }
+
+ expect_response_contain_exactly
+ end
+
it 'returns merge requests for project matching given search string for description' do
get api("/merge_requests", user), params: { project_id: project.id, search: merge_request.description }
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 7248908b494..cfa7a1a31a3 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -21,7 +21,7 @@ describe API::Projects do
let(:project) { create(:project, :repository, namespace: user.namespace) }
let(:project2) { create(:project, namespace: user.namespace) }
let(:project_member) { create(:project_member, :developer, user: user3, project: project) }
- let(:user4) { create(:user) }
+ let(:user4) { create(:user, username: 'user.with.dot') }
let(:project3) do
create(:project,
:private,
@@ -49,6 +49,27 @@ describe API::Projects do
namespace: user4.namespace)
end
+ shared_context 'with language detection' do
+ let(:ruby) { create(:programming_language, name: 'Ruby') }
+ let(:javascript) { create(:programming_language, name: 'JavaScript') }
+ let(:html) { create(:programming_language, name: 'HTML') }
+
+ let(:mock_repo_languages) do
+ {
+ project => { ruby => 0.5, html => 0.5 },
+ project3 => { html => 0.7, javascript => 0.3 }
+ }
+ end
+
+ before do
+ mock_repo_languages.each do |proj, lang_shares|
+ lang_shares.each do |lang, share|
+ create(:repository_language, project: proj, programming_language: lang, share: share)
+ end
+ end
+ end
+ end
+
describe 'GET /projects' do
shared_examples_for 'projects response' do
it 'returns an array of projects' do
@@ -344,6 +365,19 @@ describe API::Projects do
end
end
+ context 'and using the programming language filter' do
+ include_context 'with language detection'
+
+ it 'filters case-insensitively by programming language' do
+ get api('/projects', user), params: { with_programming_language: 'javascript' }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).to contain_exactly(project3.id)
+ end
+ end
+
context 'and using sorting' do
it 'returns the correct order when sorted by id' do
get api('/projects', user), params: { order_by: 'id', sort: 'desc' }
@@ -724,7 +758,7 @@ describe API::Projects do
expect(json_response['message']).to eq('404 User Not Found')
end
- it 'returns projects filtered by user' do
+ it 'returns projects filtered by user id' do
get api("/users/#{user4.id}/projects/", user)
expect(response).to have_gitlab_http_status(200)
@@ -733,6 +767,15 @@ describe API::Projects do
expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id)
end
+ it 'returns projects filtered by username' do
+ get api("/users/#{user4.username}/projects/", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id)
+ end
+
it 'returns projects filtered by minimal access level' do
private_project1 = create(:project, :private, name: 'private_project1', creator_id: user4.id, namespace: user4.namespace)
private_project2 = create(:project, :private, name: 'private_project2', creator_id: user4.id, namespace: user4.namespace)
@@ -746,6 +789,19 @@ describe API::Projects do
expect(json_response).to be_an Array
expect(json_response.map { |project| project['id'] }).to contain_exactly(private_project1.id)
end
+
+ context 'and using the programming language filter' do
+ include_context 'with language detection'
+
+ it 'filters case-insensitively by programming language' do
+ get api('/projects', user), params: { with_programming_language: 'ruby' }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).to contain_exactly(project.id)
+ end
+ end
end
describe 'POST /projects/user/:id' do
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index 811e23fb854..1f317971a66 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -127,6 +127,31 @@ describe API::Releases do
.to match_array(release.sources.map(&:url))
end
+ context "when release description contains confidential issue's link" do
+ let(:confidential_issue) do
+ create(:issue,
+ :confidential,
+ project: project,
+ title: 'A vulnerability')
+ end
+
+ let!(:release) do
+ create(:release,
+ project: project,
+ tag: 'v0.1',
+ sha: commit.id,
+ author: maintainer,
+ description: "This is confidential #{confidential_issue.to_reference}")
+ end
+
+ it "does not expose confidential issue's title" do
+ get api("/projects/#{project.id}/releases/v0.1", maintainer)
+
+ expect(json_response['description_html']).to include(confidential_issue.to_reference)
+ expect(json_response['description_html']).not_to include('A vulnerability')
+ end
+ end
+
context 'when release has link asset' do
let!(:link) do
create(:release_link,
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index ed0108c846a..d7ddd97e8c8 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -1584,7 +1584,7 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
context 'when artifacts are stored locally' do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
end
before do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 89151021f90..b381431306d 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe API::Users do
- let(:user) { create(:user) }
+ let(:user) { create(:user, username: 'user.with.dot') }
let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) }
let(:gpg_key) { create(:gpg_key, user: user) }
diff --git a/spec/requests/user_activity_spec.rb b/spec/requests/user_activity_spec.rb
new file mode 100644
index 00000000000..15666e00b9f
--- /dev/null
+++ b/spec/requests/user_activity_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Update of user activity' do
+ let(:user) { create(:user, last_activity_on: nil) }
+
+ before do
+ group = create(:group, name: 'group')
+ project = create(:project, :public, namespace: group, name: 'project')
+
+ create(:issue, project: project, iid: 10)
+ create(:merge_request, source_project: project, iid: 15)
+
+ project.add_maintainer(user)
+ end
+
+ paths_to_visit = [
+ '/group',
+ '/group/project',
+ '/groups/group/-/issues',
+ '/groups/group/-/boards',
+ '/dashboard/projects',
+ '/dashboard/snippets',
+ '/dashboard/groups',
+ '/dashboard/todos',
+ '/group/project/issues',
+ '/group/project/issues/10',
+ '/group/project/merge_requests',
+ '/group/project/merge_requests/15'
+ ]
+
+ context 'without an authenticated user' do
+ it 'does not set the last activity cookie' do
+ get "/group/project"
+
+ expect(response.cookies['user_last_activity_on']).to be_nil
+ end
+ end
+
+ context 'with an authenticated user' do
+ before do
+ login_as(user)
+ end
+
+ context 'with a POST request' do
+ it 'does not set the last activity cookie' do
+ post "/group/project/archive"
+
+ expect(response.cookies['user_last_activity_on']).to be_nil
+ end
+ end
+
+ paths_to_visit.each do |path|
+ context "on GET to #{path}" do
+ it 'updates the last activity date' do
+ expect(Users::ActivityService).to receive(:new).and_call_original
+
+ get path
+
+ expect(user.last_activity_on).to eq(Date.today)
+ end
+
+ context 'when calling it twice' do
+ it 'updates last_activity_on just once' do
+ expect(Users::ActivityService).to receive(:new).once.and_call_original
+
+ 2.times do
+ get path
+ end
+ end
+ end
+
+ context 'when last_activity_on is nil' do
+ before do
+ user.update_attribute(:last_activity_on, nil)
+ end
+
+ it 'updates the last activity date' do
+ expect(user.last_activity_on).to be_nil
+
+ get path
+
+ expect(user.last_activity_on).to eq(Date.today)
+ end
+ end
+
+ context 'when last_activity_on is stale' do
+ before do
+ user.update_attribute(:last_activity_on, 2.days.ago.to_date)
+ end
+
+ it 'updates the last activity date' do
+ get path
+
+ expect(user.last_activity_on).to eq(Date.today)
+ end
+ end
+
+ context 'when last_activity_on is up to date' do
+ before do
+ user.update_attribute(:last_activity_on, Date.today)
+ end
+
+ it 'does not try to update it' do
+ expect(Users::ActivityService).not_to receive(:new)
+
+ get path
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index ce20bf2bef6..ef76e2311b1 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -543,6 +543,76 @@ describe Issues::UpdateService, :mailer do
end
end
+ context 'when updating a single task' do
+ before do
+ update_issue(description: "- [ ] Task 1\n- [ ] Task 2")
+ end
+
+ it { expect(issue.tasks?).to eq(true) }
+
+ context 'when a task is marked as completed' do
+ before do
+ update_issue(update_task: { index: 1, checked: true, line_source: '- [ ] Task 1', line_number: 1 })
+ end
+
+ it 'creates system note about task status change' do
+ note1 = find_note('marked the task **Task 1** as completed')
+
+ expect(note1).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
+ end
+ end
+
+ context 'when a task is marked as incomplete' do
+ before do
+ update_issue(description: "- [x] Task 1\n- [X] Task 2")
+ update_issue(update_task: { index: 2, checked: false, line_source: '- [X] Task 2', line_number: 2 })
+ end
+
+ it 'creates system note about task status change' do
+ note1 = find_note('marked the task **Task 2** as incomplete')
+
+ expect(note1).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
+ end
+ end
+
+ context 'when the task position has been modified' do
+ before do
+ update_issue(description: "- [ ] Task 1\n- [ ] Task 3\n- [ ] Task 2")
+ end
+
+ it 'raises an exception' do
+ expect(Note.count).to eq(2)
+ expect do
+ update_issue(update_task: { index: 2, checked: true, line_source: '- [ ] Task 2', line_number: 2 })
+ end.to raise_error(ActiveRecord::StaleObjectError)
+ expect(Note.count).to eq(2)
+ end
+ end
+
+ context 'when the content changes but not task line number' do
+ before do
+ update_issue(description: "Paragraph\n\n- [ ] Task 1\n- [x] Task 2")
+ update_issue(description: "Paragraph with more words\n\n- [ ] Task 1\n- [x] Task 2")
+ update_issue(update_task: { index: 2, checked: false, line_source: '- [x] Task 2', line_number: 4 })
+ end
+
+ it 'creates system note about task status change' do
+ note1 = find_note('marked the task **Task 2** as incomplete')
+
+ expect(note1).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(2)
+ end
+ end
+ end
+
context 'updating labels' do
let(:label3) { create(:label, project: project) }
let(:result) { described_class.new(project, user, params).execute(issue).reload }
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 5c01463d757..3bc05182932 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -36,4 +36,13 @@ describe Members::CreateService do
expect(result[:message]).to be_present
expect(project.users).not_to include project_user
end
+
+ it 'does not add an invalid member' do
+ params = { user_ids: project_user.id.to_s, access_level: -1 }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to include(project_user.username)
+ expect(project.users).not_to include project_user
+ end
end
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index 4e64b0c9414..b46aa65818d 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -197,6 +197,24 @@ describe MergeRequests::CreateService do
expect(merge_request.actual_head_pipeline).to be_merge_request
end
+ context 'when there are no commits between source branch and target branch' do
+ let(:opts) do
+ {
+ title: 'Awesome merge_request',
+ description: 'please fix',
+ source_branch: 'not-merged-branch',
+ target_branch: 'master'
+ }
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(merge_request).to be_persisted
+
+ merge_request.reload
+ expect(merge_request.merge_request_pipelines.count).to eq(0)
+ end
+ end
+
context "when branch pipeline was created before a merge request pipline has been created" do
before do
create(:ci_pipeline, project: merge_request.source_project,
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 1169ed5f9f2..9e9dc5a576c 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -150,11 +150,15 @@ describe MergeRequests::RefreshService do
}
end
- it 'create merge request pipeline' do
+ it 'create merge request pipeline with commits' do
expect { subject }
.to change { @merge_request.merge_request_pipelines.count }.by(1)
.and change { @fork_merge_request.merge_request_pipelines.count }.by(1)
- .and change { @another_merge_request.merge_request_pipelines.count }.by(1)
+ .and change { @another_merge_request.merge_request_pipelines.count }.by(0)
+
+ expect(@merge_request.has_commits?).to be_truthy
+ expect(@fork_merge_request.has_commits?).to be_truthy
+ expect(@another_merge_request.has_commits?).to be_falsy
end
context "when branch pipeline was created before a merge request pipline has been created" do
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index 9aaccb4bffe..af4daff336b 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -123,6 +123,46 @@ describe Notes::BuildService do
end
end
+ context 'when replying to individual note' do
+ let(:note) { create(:note_on_issue) }
+
+ subject { described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute }
+
+ shared_examples 'an individual note reply' do
+ it 'builds another individual note' do
+ expect(subject).to be_valid
+ expect(subject).to be_a(Note)
+ expect(subject.discussion_id).not_to eq(note.discussion_id)
+ end
+ end
+
+ context 'when reply_to_individual_notes is disabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ end
+
+ it_behaves_like 'an individual note reply'
+ end
+
+ context 'when reply_to_individual_notes is enabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ end
+
+ it 'sets the note up to be in reply to that note' do
+ expect(subject).to be_valid
+ expect(subject).to be_a(DiscussionNote)
+ expect(subject.discussion_id).to eq(note.discussion_id)
+ end
+
+ context 'when noteable does not support replies' do
+ let(:note) { create(:note_on_commit) }
+
+ it_behaves_like 'an individual note reply'
+ end
+ end
+ end
+
it 'builds a note without saving it' do
new_note = described_class.new(project,
author,
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 1b9ba42cfd6..48f1d696ff6 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -278,5 +278,42 @@ describe Notes::CreateService do
expect(note.note).to eq(':smile:')
end
end
+
+ context 'reply to individual note' do
+ let(:existing_note) { create(:note_on_issue, noteable: issue, project: project) }
+ let(:reply_opts) { opts.merge(in_reply_to_discussion_id: existing_note.discussion_id) }
+
+ subject { described_class.new(project, user, reply_opts).execute }
+
+ context 'when reply_to_individual_notes is disabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ end
+
+ it 'creates an individual note' do
+ expect(subject.type).to eq(nil)
+ expect(subject.discussion_id).not_to eq(existing_note.discussion_id)
+ end
+
+ it 'does not convert existing note' do
+ expect { subject }.not_to change { existing_note.reload.type }
+ end
+ end
+
+ context 'when reply_to_individual_notes is enabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ end
+
+ it 'creates a DiscussionNote in reply to existing note' do
+ expect(subject).to be_a(DiscussionNote)
+ expect(subject.discussion_id).to eq(existing_note.discussion_id)
+ end
+
+ it 'converts existing note to DiscussionNote' do
+ expect { subject }.to change { existing_note.reload.type }.from(nil).to('DiscussionNote')
+ end
+ end
+ end
end
end
diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb
index 458cb8f1f31..85515d548a7 100644
--- a/spec/services/preview_markdown_service_spec.rb
+++ b/spec/services/preview_markdown_service_spec.rb
@@ -114,23 +114,4 @@ describe PreviewMarkdownService do
expect(result[:commands]).to eq 'Tags this commit to v1.2.3 with "Stable release".'
end
end
-
- it 'sets correct markdown engine' do
- service = described_class.new(project, user, { markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION })
- result = service.execute
-
- expect(result[:markdown_engine]).to eq :redcarpet
-
- service = described_class.new(project, user, { markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION })
- result = service.execute
-
- expect(result[:markdown_engine]).to eq :common_mark
- end
-
- it 'honors the legacy_render parameter' do
- service = described_class.new(project, user, { legacy_render: '1' })
- result = service.execute
-
- expect(result[:markdown_engine]).to eq :redcarpet
- end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index f71e2b4bc24..54ce33dd103 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -16,7 +16,11 @@ describe Projects::CreateService, '#execute' do
Label.create(title: "bug", template: true)
project = create_project(user, opts)
- expect(project.labels).not_to be_empty
+ created_label = project.reload.labels.last
+
+ expect(created_label.type).to eq('ProjectLabel')
+ expect(created_label.project_id).to eq(project.id)
+ expect(created_label.title).to eq('bug')
end
context 'user namespace' do
diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb
index e4d4e6ff3dd..7f5ef3129d7 100644
--- a/spec/services/projects/update_pages_configuration_service_spec.rb
+++ b/spec/services/projects/update_pages_configuration_service_spec.rb
@@ -2,23 +2,41 @@ require 'spec_helper'
describe Projects::UpdatePagesConfigurationService do
let(:project) { create(:project) }
- subject { described_class.new(project) }
+ let(:service) { described_class.new(project) }
describe "#update" do
let(:file) { Tempfile.new('pages-test') }
+ subject { service.execute }
+
after do
file.close
file.unlink
end
- it 'updates the .update file' do
- # Access this reference to ensure scoping works
- Projects::Settings # rubocop:disable Lint/Void
- expect(subject).to receive(:pages_config_file).and_return(file.path)
- expect(subject).to receive(:reload_daemon).and_call_original
+ before do
+ allow(service).to receive(:pages_config_file).and_return(file.path)
+ end
+
+ context 'when configuration changes' do
+ it 'updates the .update file' do
+ expect(service).to receive(:reload_daemon).and_call_original
+
+ expect(subject).to include(status: :success, reload: true)
+ end
+ end
+
+ context 'when configuration does not change' do
+ before do
+ # we set the configuration
+ service.execute
+ end
+
+ it 'does not update the .update file' do
+ expect(service).not_to receive(:reload_daemon)
- expect(subject.execute).to eq({ status: :success })
+ expect(subject).to include(status: :success, reload: false)
+ end
end
end
end
diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb
new file mode 100644
index 00000000000..cc64dd25085
--- /dev/null
+++ b/spec/services/task_list_toggle_service_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe TaskListToggleService do
+ let(:markdown) do
+ <<-EOT.strip_heredoc
+ * [ ] Task 1
+ * [x] Task 2
+
+ A paragraph
+
+ 1. [X] Item 1
+ - [ ] Sub-item 1
+ EOT
+ end
+
+ let(:markdown_html) do
+ <<-EOT.strip_heredoc
+ <ul data-sourcepos="1:1-3:0" class="task-list" dir="auto">
+ <li data-sourcepos="1:1-1:12" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> Task 1
+ </li>
+ <li data-sourcepos="2:1-3:0" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled checked> Task 2
+ </li>
+ </ul>
+ <p data-sourcepos="4:1-4:11" dir="auto">A paragraph</p>
+ <ol data-sourcepos="6:1-7:19" class="task-list" dir="auto">
+ <li data-sourcepos="6:1-7:19" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled checked> Item 1
+ <ul data-sourcepos="7:4-7:19" class="task-list">
+ <li data-sourcepos="7:4-7:19" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> Sub-item 1
+ </li>
+ </ul>
+ </li>
+ </ol>
+ EOT
+ end
+
+ it 'checks Task 1' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: true,
+ line_source: '* [ ] Task 1', line_number: 1)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\n"
+ expect(toggler.updated_markdown_html).to include('disabled checked> Task 1')
+ end
+
+ it 'unchecks Item 1' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: false,
+ line_source: '1. [X] Item 1', line_number: 6)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[5]).to eq "1. [ ] Item 1\n"
+ expect(toggler.updated_markdown_html).to include('disabled> Item 1')
+ end
+
+ it 'returns false if line_source does not match the text' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: false,
+ line_source: '* [x] Task Added', line_number: 2)
+
+ expect(toggler.execute).to be_falsey
+ end
+
+ it 'returns false if markdown is nil' do
+ toggler = described_class.new(nil, markdown_html,
+ toggle_as_checked: false,
+ line_source: '* [x] Task Added', line_number: 2)
+
+ expect(toggler.execute).to be_falsey
+ end
+
+ it 'returns false if markdown_html is nil' do
+ toggler = described_class.new(markdown, nil,
+ toggle_as_checked: false,
+ line_source: '* [x] Task Added', line_number: 2)
+
+ expect(toggler.execute).to be_falsey
+ end
+end
diff --git a/spec/support/helpers/stub_configuration.rb b/spec/support/helpers/stub_configuration.rb
index 2851cd9733c..ff21bbe28ca 100644
--- a/spec/support/helpers/stub_configuration.rb
+++ b/spec/support/helpers/stub_configuration.rb
@@ -56,6 +56,10 @@ module StubConfiguration
allow(Gitlab.config.lfs).to receive_messages(to_settings(messages))
end
+ def stub_external_diffs_setting(messages)
+ allow(Gitlab.config.external_diffs).to receive_messages(to_settings(messages))
+ end
+
def stub_artifacts_setting(messages)
allow(Gitlab.config.artifacts).to receive_messages(to_settings(messages))
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 58b5c6a6435..e0c50e533a6 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -42,6 +42,13 @@ module StubObjectStorage
**params)
end
+ def stub_external_diffs_object_storage(uploader = described_class, **params)
+ stub_object_storage_uploader(config: Gitlab.config.external_diffs.object_store,
+ uploader: uploader,
+ remote_directory: 'external_diffs',
+ **params)
+ end
+
def stub_lfs_object_storage(**params)
stub_object_storage_uploader(config: Gitlab.config.lfs.object_store,
uploader: LfsObjectUploader,
diff --git a/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb b/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb
index a3d31e26498..982e0317f7f 100644
--- a/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb
+++ b/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb
@@ -28,7 +28,13 @@ shared_examples 'repository lfs file load' do
end
it 'serves the file' do
- expect(controller).to receive(:send_file).with("#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: filename, disposition: 'attachment')
+ # Notice the filename= is omitted from the disposition; this is because
+ # Rails 5 will append this header in send_file
+ expect(controller).to receive(:send_file)
+ .with(
+ "#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897",
+ filename: filename,
+ disposition: %Q(attachment; filename*=UTF-8''#{filename}))
subject
@@ -56,7 +62,7 @@ shared_examples 'repository lfs file load' do
file_uri = URI.parse(response.location)
params = CGI.parse(file_uri.query)
- expect(params["response-content-disposition"].first).to eq "attachment;filename=\"#{filename}\""
+ expect(params["response-content-disposition"].first).to eq(%q(attachment; filename="lfs_object.iso"; filename*=UTF-8''lfs_object.iso))
end
end
end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index 3b8f7f5fe7d..a8fae4a88a3 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -71,21 +71,29 @@ describe 'gitlab:app namespace rake task' do
end.to raise_error(SystemExit)
end
- it 'invokes restoration on match' do
- allow(YAML).to receive(:load_file)
- .and_return({ gitlab_version: gitlab_version })
-
- expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke)
- expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
- expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
+ context 'restore with matching gitlab version' do
+ before do
+ allow(YAML).to receive(:load_file)
+ .and_return({ gitlab_version: gitlab_version })
+ expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke)
+ expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
+ end
+
+ it 'invokes restoration on match' do
+ expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout
+ end
+
+ it 'prints timestamps on messages' do
+ expect { run_rake_task('gitlab:backup:restore') }.to output(/.*\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s[-+]\d{4}\s--\s.*/).to_stdout
+ end
end
end
diff --git a/spec/uploaders/external_diff_uploader_spec.rb b/spec/uploaders/external_diff_uploader_spec.rb
new file mode 100644
index 00000000000..1c959770dc4
--- /dev/null
+++ b/spec/uploaders/external_diff_uploader_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe ExternalDiffUploader do
+ let(:diff) { create(:merge_request).merge_request_diff }
+ let(:path) { Gitlab.config.external_diffs.storage_path }
+
+ subject(:uploader) { described_class.new(diff, :external_diff) }
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[merge_request_diffs/mr-\d+],
+ cache_dir: %r[/external-diffs/tmp/cache],
+ work_dir: %r[/external-diffs/tmp/work]
+
+ context "object store is REMOTE" do
+ before do
+ stub_external_diffs_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[merge_request_diffs/mr-\d+]
+ end
+
+ describe 'migration to object storage' do
+ context 'with object storage disabled' do
+ it "is skipped" do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ diff
+ end
+ end
+
+ context 'with object storage enabled' do
+ before do
+ stub_external_diffs_setting(enabled: true)
+ stub_external_diffs_object_storage(background_upload: true)
+ end
+
+ it 'is scheduled to run after creation' do
+ expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with(described_class.name, 'MergeRequestDiff', :external_diff, kind_of(Numeric))
+
+ diff
+ end
+ end
+ end
+
+ describe 'remote file' do
+ context 'with object storage enabled' do
+ before do
+ stub_external_diffs_setting(enabled: true)
+ stub_external_diffs_object_storage
+
+ diff.update!(external_diff_store: described_class::Store::REMOTE)
+ end
+
+ it 'can store file remotely' do
+ allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async)
+
+ diff
+
+ expect(diff.external_diff_store).to eq(described_class::Store::REMOTE)
+ expect(diff.external_diff.path).not_to be_blank
+ end
+ end
+ end
+end
diff --git a/spec/validators/js_regex_validator_spec.rb b/spec/validators/js_regex_validator_spec.rb
index aeb55cdc0e5..4d3bafaf267 100644
--- a/spec/validators/js_regex_validator_spec.rb
+++ b/spec/validators/js_regex_validator_spec.rb
@@ -12,8 +12,6 @@ describe JsRegexValidator do
'' | []
'(?#comment)' | ['Regex Pattern (?#comment) can not be expressed in Javascript']
'(?(a)b|c)' | ['invalid conditional pattern: /(?(a)b|c)/i']
- '[a-z&&[^uo]]' | ["Dropped unsupported set intersection '[a-z&&[^uo]]' at index 0",
- "Dropped unsupported nested negative set data '[^uo]' at index 6"]
end
with_them do
diff --git a/spec/workers/mail_scheduler/notification_service_worker_spec.rb b/spec/workers/mail_scheduler/notification_service_worker_spec.rb
index 1033557ee88..5cfba01850c 100644
--- a/spec/workers/mail_scheduler/notification_service_worker_spec.rb
+++ b/spec/workers/mail_scheduler/notification_service_worker_spec.rb
@@ -9,6 +9,10 @@ describe MailScheduler::NotificationServiceWorker do
ActiveJob::Arguments.serialize(args)
end
+ def deserialize(args)
+ ActiveJob::Arguments.deserialize(args)
+ end
+
describe '#perform' do
it 'deserializes arguments from global IDs' do
expect(worker.notification_service).to receive(method).with(key)
@@ -42,13 +46,48 @@ describe MailScheduler::NotificationServiceWorker do
end
end
- describe '.perform_async' do
+ describe '.perform_async', :sidekiq do
+ around do |example|
+ Sidekiq::Testing.fake! { example.run }
+ end
+
it 'serializes arguments as global IDs when scheduling' do
- Sidekiq::Testing.fake! do
- described_class.perform_async(method, key)
+ described_class.perform_async(method, key)
+
+ expect(described_class.jobs.count).to eq(1)
+ expect(described_class.jobs.first).to include('args' => [method, *serialize(key)])
+ end
+
+ context 'with ActiveController::Parameters' do
+ let(:parameters) { ActionController::Parameters.new(hash) }
+
+ let(:hash) do
+ {
+ "nested" => {
+ "hash" => true
+ }
+ }
+ end
+
+ context 'when permitted' do
+ before do
+ parameters.permit!
+ end
+
+ it 'serializes as a serializable Hash' do
+ described_class.perform_async(method, parameters)
- expect(described_class.jobs.count).to eq(1)
- expect(described_class.jobs.first).to include('args' => [method, *serialize(key)])
+ expect(described_class.jobs.count).to eq(1)
+ expect(deserialize(described_class.jobs.first['args']))
+ .to eq([method, hash])
+ end
+ end
+
+ context 'when not permitted' do
+ it 'fails to serialize' do
+ expect { described_class.perform_async(method, parameters) }
+ .to raise_error(ActionController::UnfilteredParameters)
+ end
end
end
end
diff --git a/yarn.lock b/yarn.lock
index 5c9139fdbfa..423c7f75d47 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -658,10 +658,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.48.0.tgz#7b2e20e357d85aa46e905e6ca51b0b4184ae2794"
integrity sha512-9lRsfqN0W3JxopiXnTzvDY31O465jMTGNKpiOCXy7uAMfwZA6UsRsc7Pp369uKnOLR0duXUGOxOv4NGsK6AeXw==
-"@gitlab/ui@^1.22.1":
- version "1.22.1"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.22.1.tgz#92ed77216c5702776049b9ac41eb717c1acd864e"
- integrity sha512-pWbEaXOOcp8Xt2TjJtPas3lXwWVvizrBOf0M8yN0XAn2GgIRCVnRMpjNEN7/oNeBcEM9CrmPYApEM/hZO+maqQ==
+"@gitlab/ui@^2.0.2":
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-2.0.2.tgz#611571c931181fb783f57f712e1c2388059b301b"
+ integrity sha512-rUWVhWmM9EkwIEruYJEjizrQKe7TzNyKArwWY/nfEL4HptDtwbe+xHfR8IJHbpql3oI87cTO3BheMxYF6b2Ebg==
dependencies:
babel-standalone "^6.26.0"
bootstrap-vue "^2.0.0-rc.11"
@@ -3032,10 +3032,10 @@ decamelize@^2.0.0:
dependencies:
xregexp "4.0.0"
-deckar01-task_list@^2.0.1:
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.1.tgz#fdcfb6ab5717055a82f29e863a49990a043a06a9"
- integrity sha512-i5fT8QxJ9iV6dfgy5U0NHW91O5cKsvDc4u8JNMnZ6efQc356bA9vKuXO3732agSry+bO6TolzTmuqSRi4tkkeA==
+deckar01-task_list@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.2.0.tgz#5cc3ea06f01d3d786b1a667064a462eb5d069bd3"
+ integrity sha512-NUfu5ARoD9SC2k+fBT5cBer59iKfEdawPrmfqp5+zAahTECb8z9dsuS1Xnx7jzFAmCCLnEs3z/aYucYXzNrKkQ==
decode-uri-component@^0.2.0:
version "0.2.0"
@@ -3264,10 +3264,12 @@ doctrine@^2.1.0:
dependencies:
esutils "^2.0.2"
-document-register-element@1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.3.0.tgz#fb3babb523c74662be47be19c6bc33e71990d940"
- integrity sha1-+zurtSPHRmK+R74Zxrwz5xmQ2UA=
+document-register-element@1.13.1:
+ version "1.13.1"
+ resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.13.1.tgz#dad8cb7be38e04ee3f56842e6cf81af46c1249ba"
+ integrity sha512-92ZyLDKg9j4rOll//NNXj25f+8rAzOkYsGJonhugKwXfeqH7bzs8Ucpvey0WzZ2ZzKdrvW9RnUw3UyOZ/uhBFw==
+ dependencies:
+ lightercollective "^0.1.0"
dom-serialize@^2.2.0:
version "2.2.1"
@@ -3416,6 +3418,11 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
+emoji-regex@^7.0.3:
+ version "7.0.3"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
+ integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
+
emoji-unicode-version@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/emoji-unicode-version/-/emoji-unicode-version-0.2.1.tgz#0ebf3666b5414097971d34994e299fce75cdbafc"