Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobert Speicher <rspeicher@gmail.com>2018-12-18 04:50:03 +0300
committerRobert Speicher <rspeicher@gmail.com>2018-12-18 04:50:03 +0300
commit5a013145c910abc7ad016da7bcf2cdbd65d650a2 (patch)
tree78990687304961b13e686a3bcbf8525f1c9091a7
parent7d28248d1b3392246eac064b8ef305eb4ccd0741 (diff)
parentb0cb0d7fd9a4483441866261dcba214a3c94d8c6 (diff)
Merge branch '11-6-stable-prepare-rc8' into '11-6-stable'
Prepare 11.6 RC8 release See merge request gitlab-org/gitlab-ce!23823
-rw-r--r--.gitlab-ci.yml4
-rw-r--r--.gitlab/CODEOWNERS.disabled (renamed from .gitlab/CODEOWNERS)4
-rw-r--r--.gitlab/issue_templates/Feature proposal.md12
-rw-r--r--GITLAB_WORKHORSE_VERSION2
-rw-r--r--Gemfile.lock2
-rw-r--r--app/assets/javascripts/api.js27
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue20
-rw-r--r--app/assets/javascripts/diffs/components/app.vue11
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue7
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue1
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue12
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue6
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue9
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue6
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js28
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js47
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js6
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js42
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js28
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue97
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue10
-rw-r--r--app/assets/javascripts/mr_notes/index.js2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue38
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue5
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue35
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue9
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue35
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue12
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue12
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js53
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js28
-rw-r--r--app/assets/javascripts/notes/stores/getters.js13
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js12
-rw-r--r--app/assets/javascripts/registry/components/app.vue12
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue61
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue56
-rw-r--r--app/assets/javascripts/registry/stores/actions.js36
-rw-r--r--app/assets/javascripts/registry/stores/index.js28
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js1
-rw-r--r--app/assets/javascripts/registry/stores/state.js26
-rw-r--r--app/assets/javascripts/user_popovers.js107
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue94
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue60
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue136
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue104
-rw-r--r--app/assets/stylesheets/application.scss5
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss6
-rw-r--r--app/assets/stylesheets/components/popover.scss9
-rw-r--r--app/assets/stylesheets/csslab.scss1
-rw-r--r--app/assets/stylesheets/framework/buttons.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss17
-rw-r--r--app/assets/stylesheets/framework/files.scss2
-rw-r--r--app/assets/stylesheets/framework/header.scss14
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss23
-rw-r--r--app/assets/stylesheets/framework/typography.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss7
-rw-r--r--app/assets/stylesheets/pages/diff.scss1
-rw-r--r--app/assets/stylesheets/pages/profile.scss7
-rw-r--r--app/assets/stylesheets/pages/search.scss2
-rw-r--r--app/assets/stylesheets/pages/wiki.scss2
-rw-r--r--app/controllers/concerns/issuable_collections.rb2
-rw-r--r--app/controllers/concerns/preview_markdown.rb10
-rw-r--r--app/controllers/concerns/renders_commits.rb6
-rw-r--r--app/controllers/projects/commits_controller.rb5
-rw-r--r--app/controllers/projects/compare_controller.rb6
-rw-r--r--app/controllers/projects/environments_controller.rb4
-rw-r--r--app/helpers/dropdowns_helper.rb9
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/nav_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb12
-rw-r--r--app/helpers/selects_helper.rb5
-rw-r--r--app/helpers/sorting_helper.rb2
-rw-r--r--app/models/concerns/noteable.rb4
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb2
-rw-r--r--app/models/diff_note.rb13
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb5
-rw-r--r--app/models/namespace.rb1
-rw-r--r--app/models/note.rb12
-rw-r--r--app/models/project.rb11
-rw-r--r--app/models/protected_branch.rb1
-rw-r--r--app/models/protected_tag.rb1
-rw-r--r--app/models/repository.rb1
-rw-r--r--app/models/suggestion.rb61
-rw-r--r--app/policies/suggestion_policy.rb11
-rw-r--r--app/serializers/diff_line_entity.rb2
-rw-r--r--app/serializers/issue_board_entity.rb2
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/note_entity.rb1
-rw-r--r--app/serializers/suggestion_entity.rb17
-rw-r--r--app/services/notes/create_service.rb1
-rw-r--r--app/services/notes/update_service.rb11
-rw-r--r--app/services/preview_markdown_service.rb8
-rw-r--r--app/services/suggestions/apply_service.rb54
-rw-r--r--app/services/suggestions/create_service.rb56
-rw-r--r--app/validators/url_validator.rb1
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml35
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml4
-rw-r--r--app/views/projects/blob/_blob.html.haml2
-rw-r--r--app/views/projects/blob/preview.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/wikis/show.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml2
-rw-r--r--app/views/users/show.html.haml12
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb1
-rw-r--r--changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml5
-rw-r--r--changelogs/unreleased/50157-extended-user-centric-tooltips.yml5
-rw-r--r--changelogs/unreleased/51122-fix-navigating-discussions.yml5
-rw-r--r--changelogs/unreleased/52774-fix-svgs-in-ie-11.yml5
-rw-r--r--changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml5
-rw-r--r--changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml5
-rw-r--r--changelogs/unreleased/commit-badge-style-fix.yml5
-rw-r--r--changelogs/unreleased/define-default-value-for-only-except-keys.yml2
-rw-r--r--changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml5
-rw-r--r--changelogs/unreleased/profile-fixing.yml5
-rw-r--r--changelogs/unreleased/suggest-change-to-diff-line.yml5
-rw-r--r--changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml5
-rw-r--r--changelogs/unreleased/winh-dropdown-item-padding.yml5
-rw-r--r--changelogs/unreleased/winh-resolved-discussions-reply-field.yml5
-rw-r--r--config/application.rb1
-rw-r--r--config/dependency_decisions.yml7
-rw-r--r--db/migrate/20181123144235_create_suggestions.rb20
-rw-r--r--db/post_migrate/20161221153951_rename_reserved_project_names.rb1
-rw-r--r--db/post_migrate/20170313133418_rename_more_reserved_project_names.rb1
-rw-r--r--db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb38
-rw-r--r--db/schema.rb13
-rw-r--r--doc/api/jobs.md37
-rw-r--r--doc/ci/interactive_web_terminal/index.md8
-rw-r--r--doc/user/project/web_ide/index.md12
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/entities.rb12
-rw-r--r--lib/api/job_artifacts.rb24
-rw-r--r--lib/api/suggestions.rb31
-rw-r--r--lib/banzai/filter/suggestion_filter.rb25
-rw-r--r--lib/banzai/filter/syntax_highlight_filter.rb2
-rw-r--r--lib/banzai/filter/user_reference_filter.rb2
-rw-r--r--lib/banzai/pipeline/gfm_pipeline.rb1
-rw-r--r--lib/banzai/pipeline/post_process_pipeline.rb3
-rw-r--r--lib/banzai/suggestions_parser.rb14
-rw-r--r--lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb99
-rw-r--r--lib/gitlab/bitbucket_server_import/importer.rb2
-rw-r--r--lib/gitlab/ci/build/policy/refs.rb12
-rw-r--r--lib/gitlab/ci/config/entry/job.rb11
-rw-r--r--lib/gitlab/ci/config/entry/policy.rb3
-rw-r--r--lib/gitlab/diff/file.rb10
-rw-r--r--lib/gitlab/diff/line.rb4
-rw-r--r--lib/gitlab/gitaly_client.rb2
-rw-r--r--lib/gitlab/import_export/repo_restorer.rb1
-rw-r--r--lib/gitlab/url_blocker.rb9
-rw-r--r--lib/gitlab/usage_data.rb2
-rw-r--r--locale/gitlab.pot33
-rw-r--r--package.json6
-rw-r--r--qa/qa/page/merge_request/new.rb8
-rw-r--r--qa/qa/page/merge_request/show.rb7
-rw-r--r--qa/qa/resource/merge_request.rb6
-rw-r--r--qa/qa/runtime/browser.rb7
-rw-r--r--qa/qa/runtime/env.rb5
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb4
-rw-r--r--spec/controllers/projects/commits_controller_spec.rb30
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb2
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb2
-rw-r--r--spec/db/schema_spec.rb3
-rw-r--r--spec/factories/suggestions.rb20
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb18
-rw-r--r--spec/features/merge_request/user_suggests_changes_on_diff_spec.rb85
-rw-r--r--spec/features/projects/files/user_browses_files_spec.rb1
-rw-r--r--spec/fixtures/api/schemas/entities/diff_line.json3
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_widget.json3
-rw-r--r--spec/helpers/sorting_helper_spec.rb6
-rw-r--r--spec/javascripts/api_spec.js34
-rw-r--r--spec/javascripts/boards/components/issue_due_date_spec.js7
-rw-r--r--spec/javascripts/boards/mock_data.js69
-rw-r--r--spec/javascripts/diffs/components/diff_content_spec.js1
-rw-r--r--spec/javascripts/diffs/mock_data/diff_discussions.js15
-rw-r--r--spec/javascripts/diffs/store/actions_spec.js61
-rw-r--r--spec/javascripts/diffs/store/mutations_spec.js85
-rw-r--r--spec/javascripts/image_diff/helpers/badge_helper_spec.js4
-rw-r--r--spec/javascripts/lib/utils/users_cache_spec.js110
-rw-r--r--spec/javascripts/notes/components/note_edited_text_spec.js2
-rw-r--r--spec/javascripts/notes/components/note_header_spec.js3
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js1
-rw-r--r--spec/javascripts/notes/mock_data.js4
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js2
-rw-r--r--spec/javascripts/registry/components/app_spec.js61
-rw-r--r--spec/javascripts/registry/components/collapsible_container_spec.js43
-rw-r--r--spec/javascripts/registry/stores/actions_spec.js50
-rw-r--r--spec/javascripts/user_popovers_spec.js66
-rw-r--r--spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js114
-rw-r--r--spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js234
-rw-r--r--spec/javascripts/vue_shared/components/markdown/header_spec.js15
-rw-r--r--spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js69
-rw-r--r--spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js79
-rw-r--r--spec/javascripts/vue_shared/components/markdown/suggestions_spec.js125
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js13
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js133
-rw-r--r--spec/lib/banzai/filter/suggestion_filter_spec.rb35
-rw-r--r--spec/lib/banzai/filter/user_reference_filter_spec.rb2
-rw-r--r--spec/lib/banzai/suggestions_parser_spec.rb32
-rw-r--r--spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb57
-rw-r--r--spec/lib/gitlab/ci/config/entry/global_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb3
-rw-r--r--spec/lib/gitlab/ci/config/entry/jobs_spec.rb6
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/url_blocker_spec.rb21
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb1
-rw-r--r--spec/migrations/populate_mr_metrics_with_events_data_spec.rb47
-rw-r--r--spec/models/diff_note_spec.rb18
-rw-r--r--spec/models/members/project_member_spec.rb4
-rw-r--r--spec/models/project_spec.rb43
-rw-r--r--spec/models/suggestion_spec.rb57
-rw-r--r--spec/requests/api/jobs_spec.rb130
-rw-r--r--spec/requests/api/suggestions_spec.rb83
-rw-r--r--spec/serializers/issue_board_entity_spec.rb31
-rw-r--r--spec/serializers/suggestion_entity_spec.rb23
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb89
-rw-r--r--spec/services/notes/update_service_spec.rb23
-rw-r--r--spec/services/preview_markdown_service_spec.rb25
-rw-r--r--spec/services/suggestions/apply_service_spec.rb229
-rw-r--r--spec/services/suggestions/create_service_spec.rb110
-rw-r--r--spec/support/features/discussion_comments_shared_example.rb10
-rw-r--r--spec/support/helpers/javascript_fixtures_helpers.rb7
-rw-r--r--spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb32
-rw-r--r--spec/uploaders/namespace_file_uploader_spec.rb27
-rw-r--r--spec/uploaders/personal_file_uploader_spec.rb27
-rw-r--r--spec/validators/url_validator_spec.rb29
-rw-r--r--yarn.lock23
249 files changed, 5095 insertions, 489 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 46604317232..4ae319d64d7 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -488,7 +488,6 @@ danger-review:
<<: *pull-cache
image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger
stage: test
- allow_failure: true
dependencies: []
before_script: []
only:
@@ -555,7 +554,8 @@ docs lint:
# Build HTML from Markdown
- bundle exec nanoc
# Check the internal links
- - bundle exec nanoc check internal_links
+ # Disabled until https://gitlab.com/gitlab-com/gitlab-docs/issues/305 is resolved
+ # - bundle exec nanoc check internal_links
downtime_check:
<<: *rake-exec
diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS.disabled
index a4b773b15a9..82e914a502f 100644
--- a/.gitlab/CODEOWNERS
+++ b/.gitlab/CODEOWNERS.disabled
@@ -6,8 +6,8 @@
/doc/ @axil @marcia
# Frontend maintainers should see everything in `app/assets/`
-app/assets/ @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann
-*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann
+app/assets/ @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya
+*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya
# Someone from the database team should review changes in `db/`
db/ @abrandl @NikolayS
diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md
index c4065d3c4ea..ad517f0457d 100644
--- a/.gitlab/issue_templates/Feature proposal.md
+++ b/.gitlab/issue_templates/Feature proposal.md
@@ -1,14 +1,22 @@
### Problem to solve
+<!--- What problem do we solve? -->
+
+### Target audience
+
+<!--- For whom are we doing this? Include either a persona from https://design.gitlab.com/#/getting-started/personas or define a specific company role. e.a. "Release Manager" or "Security Analyst" -->
+
### Further details
-(Include use cases, benefits, and/or goals)
+<!--- Include use cases, benefits, and/or goals (contributes to our vision?) -->
### Proposal
+<!--- How are we going to solve the problem? -->
+
### What does success look like, and how can we measure that?
-(If no way to measure success, link to an issue that will implement a way to measure this)
+<!--- If no way to measure success, link to an issue that will implement a way to measure this -->
### Links / references
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index 18bb4182dd0..93c8ddab9fe 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-7.5.0
+7.6.0
diff --git a/Gemfile.lock b/Gemfile.lock
index 608d1814127..430025c7bde 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -807,7 +807,7 @@ GEM
selenium-webdriver (3.12.0)
childprocess (~> 0.5)
rubyzip (~> 1.2)
- sentry-raven (2.7.2)
+ sentry-raven (2.7.4)
faraday (>= 0.7.6, < 1.0)
settingslogic (2.0.9)
sexp_processor (4.11.0)
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index de003e70e61..7607c4b3b79 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -21,8 +21,11 @@ const Api = {
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
usersPath: '/api/:version/users.json',
- userStatusPath: '/api/:version/user/status',
+ userPath: '/api/:version/users/:id',
+ userStatusPath: '/api/:version/users/:id/status',
+ userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
+ applySuggestionPath: '/api/:version/suggestions/:id/apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
@@ -183,6 +186,12 @@ const Api = {
});
},
+ applySuggestion(id) {
+ const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
+
+ return axios.put(url);
+ },
+
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
@@ -254,6 +263,20 @@ const Api = {
});
},
+ user(id, options) {
+ const url = Api.buildUrl(this.userPath).replace(':id', encodeURIComponent(id));
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
+ userStatus(id, options) {
+ const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id));
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
@@ -276,7 +299,7 @@ const Api = {
},
postUserStatus({ emoji, message }) {
- const url = Api.buildUrl(this.userStatusPath);
+ const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, {
emoji,
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index a2d4331b6d1..fc9286d15e6 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user';
+import initUserPopovers from '../../user_popovers';
// Render GitLab flavoured Markdown
//
@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
+ initUserPopovers(this.find('.gfm-project_member').get());
return this;
};
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 15937b1091a..e038198e6f0 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -15,6 +15,16 @@ export default {
type: String,
required: true,
},
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'bottom',
+ },
},
computed: {
title() {
@@ -66,15 +76,13 @@ export default {
<template>
<span>
- <span ref="issueDueDate" class="board-card-info card-number">
- <icon
- :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }"
- name="calendar"
- /><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
+ <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
+ <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
+ <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
</span>
- <gl-tooltip :target="() => $refs.issueDueDate" placement="bottom">
+ <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span> <br />
<span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
</gl-tooltip>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index bf9244df7f7..7e5aca88984 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -42,6 +42,16 @@ export default {
type: Object,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ changesEmptyStateIllustration: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -196,6 +206,7 @@ export default {
v-for="file in diffFiles"
:key="file.newPath"
:file="file"
+ :help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
/>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 11cc4c09fed..ac963f2971e 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -23,6 +23,11 @@ export default {
type: Object,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapState({
@@ -74,11 +79,13 @@ export default {
v-if="isInlineView"
:diff-file="diffFile"
:diff-lines="diffFile.highlighted_diff_lines || []"
+ :help-page-path="helpPagePath"
/>
<parallel-diff-view
v-if="isParallelView"
:diff-file="diffFile"
:diff-lines="diffFile.parallel_diff_lines || []"
+ :help-page-path="helpPagePath"
/>
</template>
<diff-viewer
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index bee29b04e92..b2021cd6061 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -13,6 +13,11 @@ export default {
type: Array,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
shouldCollapseDiscussions: {
type: Boolean,
required: false,
@@ -23,6 +28,11 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
...mapActions(['toggleDiscussion']),
@@ -72,6 +82,8 @@ export default {
:render-diff-file="false"
:always-expanded="true"
:discussions-by-diff-order="true"
+ :line="line"
+ :help-page-path="helpPagePath"
@noteDeleted="deleteNoteHandler"
>
<span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 3b2a0d156ca..449f7007077 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -4,6 +4,7 @@ import _ from 'underscore';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui';
+import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
@@ -22,6 +23,11 @@ export default {
type: Boolean,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -75,6 +81,9 @@ export default {
}
},
},
+ created() {
+ eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
+ },
methods: {
...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']),
handleToggle() {
@@ -160,6 +169,7 @@ export default {
v-if="!isCollapsed && file.renderIt"
:class="{ hidden: isCollapsed || file.too_large }"
:diff-file="file"
+ :help-page-path="helpPagePath"
/>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed">
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 9fd02acbd6e..e7569ba7b84 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -94,6 +94,7 @@ export default {
ref="noteForm"
:is-editing="true"
:line-code="line.line_code"
+ :line="line"
save-button-title="Comment"
class="diff-comment-form"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index aa40b24950a..814ee0b7c02 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
className() {
@@ -38,7 +43,12 @@ export default {
<tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content" colspan="3">
<div class="content">
- <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" />
+ <diff-discussions
+ v-if="line.discussions.length"
+ :line="line"
+ :discussions="line.discussions"
+ :help-page-path="helpPagePath"
+ />
<diff-line-note-form
v-if="line.hasForm"
:diff-file-hash="diffFileHash"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index 6a0ce760e6d..9310e2b7ca9 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -17,6 +17,11 @@ export default {
type: Array,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapGetters('diffs', ['commitId']),
@@ -47,6 +52,7 @@ export default {
:key="`icr-${index}`"
:diff-file-hash="diffFile.file_hash"
:line="line"
+ :help-page-path="helpPagePath"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index b98463d3dd3..a65cf025cde 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -20,6 +20,11 @@ export default {
type: Number,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasExpandedDiscussionOnLeft() {
@@ -87,6 +92,8 @@ export default {
<diff-discussions
v-if="line.left.discussions.length"
:discussions="line.left.discussions"
+ :line="line.left"
+ :help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
@@ -102,6 +109,8 @@ export default {
<diff-discussions
v-if="line.right.discussions.length"
:discussions="line.right.discussions"
+ :line="line.right"
+ :help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index 9a6e0e82529..e6bc0daebb3 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -17,6 +17,11 @@ export default {
type: Array,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapGetters('diffs', ['commitId']),
@@ -49,6 +54,7 @@ export default {
:line="line"
:diff-file-hash="diffFile.file_hash"
:line-index="index"
+ :help-page-path="helpPagePath"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 06ef4207d85..41e6bd81072 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -16,6 +16,7 @@ export default function initDiffsApp(store) {
return {
endpoint: dataset.endpoint,
projectPath: dataset.projectPath,
+ helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
};
},
@@ -30,6 +31,7 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint,
currentUser: this.currentUser,
projectPath: this.projectPath,
+ helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
},
});
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 952963e0711..00a4bb6d3a3 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils';
+import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
+import eventHub from '../../notes/event_hub';
import { getDiffPositionByLineCode, getNoteFormData } from './utils';
import * as types from './mutation_types';
import {
@@ -53,6 +54,10 @@ export const assignDiscussionsToDiff = (
diffPositionByLineCode,
});
});
+
+ Vue.nextTick(() => {
+ eventHub.$emit('scrollToDiscussion');
+ });
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
@@ -60,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
+export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
+ const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
+
+ if (discussion) {
+ const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
+
+ if (file) {
+ if (!file.renderIt) {
+ commit(types.RENDER_FILE, file);
+ }
+
+ if (file.collapsed) {
+ eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
+ scrollToElement(document.getElementById(file.file_hash));
+ } else {
+ eventHub.$emit('scrollToDiscussion');
+ }
+ }
+ }
+};
+
export const startRenderDiffsQueue = ({ state, commit }) => {
const checkItem = () =>
new Promise(resolve => {
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 331fb052371..2ea884d1293 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -123,22 +123,23 @@ export default {
diffPosition: diffPositionByLineCode[line.line_code],
latestDiff,
});
+ const mapDiscussions = (line, extraCheck = () => true) => ({
+ ...line,
+ discussions: extraCheck()
+ ? line.discussions
+ .filter(() => !line.discussions.some(({ id }) => discussion.id === id))
+ .concat(lineCheck(line) ? discussion : line.discussions)
+ : [],
+ });
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
- file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => {
- if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) {
- return {
- ...line,
- discussions: line.discussions.concat(discussion),
- };
- }
-
- return line;
- });
+ file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
+ mapDiscussions(line),
+ );
}
if (file.parallel_diff_lines) {
@@ -148,20 +149,8 @@ export default {
if (left || right) {
return {
- left: {
- ...line.left,
- discussions:
- left && !line.left.discussions.some(({ id }) => id === discussion.id)
- ? line.left.discussions.concat(discussion)
- : (line.left && line.left.discussions) || [],
- },
- right: {
- ...line.right,
- discussions:
- right && !left && !line.right.discussions.some(({ id }) => id === discussion.id)
- ? line.right.discussions.concat(discussion)
- : (line.right && line.right.discussions) || [],
- },
+ left: line.left ? mapDiscussions(line.left) : null,
+ right: line.right ? mapDiscussions(line.right, () => !left) : null,
};
}
@@ -170,7 +159,7 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
- file.discussions = file.discussions.concat(discussion);
+ file.discussions = (file.discussions || []).concat(discussion);
}
return file;
@@ -180,7 +169,7 @@ export default {
});
},
- [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode, id }) {
+ [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
if (selectedFile.parallel_diff_lines) {
@@ -193,7 +182,7 @@ export default {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
Object.assign(targetLine[side], {
- discussions: [],
+ discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
});
}
}
@@ -205,14 +194,14 @@ export default {
if (targetInlineLine) {
Object.assign(targetInlineLine, {
- discussions: [],
+ discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
});
}
}
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
- discussion => discussion.id !== id,
+ discussion => discussion.notes.length,
);
}
}
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index eddaeda9578..000157efad0 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -12,7 +12,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
}
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
- const buttonEl = createImageBadge(noteId, coordinate, ['badge']);
+ const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
buttonEl.innerText = badgeText;
containerEl.appendChild(buttonEl);
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 040d0bc659e..9e22cdc04e9 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -192,8 +192,12 @@ export const contentTop = () => {
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
+ const diffFileLargeEnoughScreen =
+ 'matchMedia' in window ? window.matchMedia('min-width: 768') : true;
+ const diffFileTitleBar =
+ (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
- return perfBar + mrTabsHeight + headerHeight + diffFilesChanged;
+ return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
};
export const scrollToElement = element => {
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 3618c6af7e2..c095a017866 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) {
}
}
-function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) {
+function moveCursor({
+ textArea,
+ tag,
+ cursorOffset,
+ positionBetweenTags,
+ removedLastNewLine,
+ select,
+}) {
var pos;
if (!textArea.setSelectionRange) {
return;
@@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se
pos -= 1;
}
+ if (cursorOffset) {
+ pos -= cursorOffset;
+ }
+
return textArea.setSelectionRange(pos, pos);
}
}
-export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) {
+export function insertMarkdownText({
+ textArea,
+ text,
+ tag,
+ cursorOffset,
+ blockTag,
+ selected,
+ wrap,
+ select,
+}) {
var textToInsert,
selectedSplit,
startChar,
@@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
+ cursorOffset,
positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine,
select,
});
}
-function updateText({ textArea, tag, blockTag, wrap, select }) {
+function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
- selected = selectedText(text, textArea);
+ selected = selectedText(text, textArea) || tagContent;
$textArea.focus();
- return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select });
+ return insertMarkdownText({
+ textArea,
+ text,
+ tag,
+ cursorOffset,
+ blockTag,
+ selected,
+ wrap,
+ select,
+ });
}
export function addMarkdownListeners(form) {
@@ -178,9 +208,11 @@ export function addMarkdownListeners(form) {
return updateText({
textArea: $this.closest('.md-area').find('textarea'),
tag: $this.data('mdTag'),
+ cursorOffset: $this.data('mdCursorOffset'),
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
+ tagContent: $this.data('mdTagContent').toString(),
});
});
}
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
index c0d45e017b4..9f980fd4899 100644
--- a/app/assets/javascripts/lib/utils/users_cache.js
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -22,6 +22,34 @@ class UsersCache extends Cache {
});
// missing catch is intentional, error handling depends on use case
}
+
+ retrieveById(userId) {
+ if (this.hasData(userId) && this.get(userId).username) {
+ return Promise.resolve(this.get(userId));
+ }
+
+ return Api.user(userId).then(({ data }) => {
+ this.internalStorage[userId] = data;
+ return data;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
+
+ retrieveStatusById(userId) {
+ if (this.hasData(userId) && this.get(userId).status) {
+ return Promise.resolve(this.get(userId).status);
+ }
+
+ return Api.userStatus(userId).then(({ data }) => {
+ if (!this.hasData(userId)) {
+ this.internalStorage[userId] = {};
+ }
+ this.internalStorage[userId].status = data;
+
+ return data;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
}
export default new UsersCache();
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index a88b575ad99..c866e8d180a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
+import initUserPopovers from './user_popovers';
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
@@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle();
initLogoAnimation();
initUsagePingConsent();
+ initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
new file mode 100644
index 00000000000..12224e36ba2
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui';
+import dateFormat from 'dateformat';
+
+export default {
+ components: {
+ GlAreaChart,
+ },
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator(data) {
+ return (
+ data.queries &&
+ Array.isArray(data.queries) &&
+ data.queries.filter(query => {
+ if (Array.isArray(query.result)) {
+ return (
+ query.result.filter(res => Array.isArray(res.values)).length === query.result.length
+ );
+ }
+ return false;
+ }).length === data.queries.length
+ );
+ },
+ },
+ },
+ 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;
+ }),
+ );
+ return accumulator;
+ }, {});
+ },
+ chartOptions() {
+ return {
+ xAxis: {
+ name: 'Time',
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, 'h:MMtt'),
+ },
+ nameTextStyle: {
+ padding: [18, 0, 0, 0],
+ },
+ },
+ yAxis: {
+ name: this.graphData.y_label,
+ axisLabel: {
+ formatter: value => value.toFixed(3),
+ },
+ nameTextStyle: {
+ padding: [0, 0, 36, 0],
+ },
+ },
+ legend: {
+ formatter: this.xAxisLabel,
+ },
+ };
+ },
+ xAxisLabel() {
+ return this.graphData.queries.map(query => query.label).join(', ');
+ },
+ },
+ methods: {
+ formatTooltipText(params) {
+ const [date, value] = params;
+ return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
+ },
+ onCreated(chart) {
+ this.$emit('created', chart);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-graph">
+ <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
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ @created="onCreated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 218c508a608..2d9c5050c9b 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -4,6 +4,7 @@ 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';
@@ -12,6 +13,7 @@ import eventHub from '../event_hub';
export default {
components: {
+ MonitorAreaChart,
Graph,
GraphGroup,
EmptyState,
@@ -102,6 +104,9 @@ export default {
};
},
computed: {
+ graphComponent() {
+ return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
+ },
forceRedraw() {
return this.elWidth;
},
@@ -207,7 +212,8 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
- <graph
+ <component
+ :is="graphComponent"
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
@@ -220,7 +226,7 @@ export default {
>
<!-- EE content -->
{{ null }}
- </graph>
+ </component>
</graph-group>
</div>
<empty-state
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 1c98683c597..e4d72eb8318 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -33,6 +33,7 @@ export default function initMrNotes() {
noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
+ helpPagePath: notesDataset.helpPagePath,
};
},
computed: {
@@ -71,6 +72,7 @@ export default function initMrNotes() {
notesData: this.notesData,
userData: this.currentUserData,
shouldShow: this.activeTab === 'show',
+ helpPagePath: this.helpPagePath,
},
});
},
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index c0bee600181..bcf5d334da4 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,10 +1,12 @@
<script>
+import { mapActions } from 'vuex';
import $ from 'jquery';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
import noteForm from './note_form.vue';
import autosave from '../mixins/autosave';
+import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
@@ -12,6 +14,7 @@ export default {
noteAwardsList,
noteAttachment,
noteForm,
+ Suggestions,
},
mixins: [autosave],
props: {
@@ -19,6 +22,11 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -28,11 +36,22 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
noteBody() {
return this.note.note;
},
+ hasSuggestion() {
+ return this.note.suggestions && this.note.suggestions.length;
+ },
+ lineType() {
+ return this.line ? this.line.type : null;
+ },
},
mounted() {
this.renderGFM();
@@ -53,6 +72,7 @@ export default {
}
},
methods: {
+ ...mapActions(['submitSuggestion']),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
@@ -62,19 +82,35 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
+ applySuggestion({ suggestionId, flashContainer, callback }) {
+ const { discussion_id: discussionId, id: noteId } = this.note;
+
+ this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
+ },
},
};
</script>
<template>
<div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
- <div class="note-text md" v-html="note.note_html"></div>
+ <suggestions
+ v-if="hasSuggestion && !isEditing"
+ :suggestions="note.suggestions"
+ :note-html="note.note_html"
+ :line-type="lineType"
+ :help-page-path="helpPagePath"
+ @apply="applySuggestion"
+ />
+ <div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
v-if="isEditing"
ref="noteForm"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
+ :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_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 3d3dbbd7fe1..15ce49d7c31 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -39,7 +39,10 @@ export default {
<div :class="className">
{{ actionText }}
<template v-if="editedBy">
- by <a :href="editedBy.path" class="js-vue-author author-link"> {{ editedBy.name }} </a>
+ by
+ <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link">
+ {{ editedBy.name }}
+ </a>
</template>
{{ actionDetailText }}
<time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 95164183ccb..9b7f3d3588d 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,4 +1,5 @@
<script>
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
@@ -53,6 +54,21 @@ export default {
required: false,
default: false,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ note: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -79,7 +95,8 @@ export default {
return '#';
},
markdownPreviewPath() {
- return this.getNoteableDataByProp('preview_note_path');
+ const notable = this.getNoteableDataByProp('preview_note_path');
+ return mergeUrlParams({ preview_suggestions: true }, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -93,6 +110,18 @@ export default {
isDisabled() {
return !this.updatedNoteBody.length || this.isSubmitting;
},
+ discussionNote() {
+ const discussionNote = this.discussion.id
+ ? this.getDiscussionLastNote(this.discussion)
+ : this.note;
+ return discussionNote || {};
+ },
+ canSuggest() {
+ return (
+ this.getNoteableData.can_receive_suggestion &&
+ (this.line && this.line.can_receive_suggestion)
+ );
+ },
},
watch: {
noteBody() {
@@ -171,7 +200,11 @@ export default {
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
+ :line="line"
+ :note="discussionNote"
+ :can-suggest="canSuggest"
:add-spacing-classes="false"
+ :help-page-path="helpPagePath"
>
<textarea
id="note_note"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index e1a58e7cb26..7b39901024d 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -73,7 +73,14 @@ export default {
{{ __('Toggle discussion') }}
</button>
</div>
- <a v-if="hasAuthor" v-once :href="author.path">
+ <a
+ v-if="hasAuthor"
+ v-once
+ :href="author.path"
+ class="js-user-link"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ >
<span class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light"> @{{ author.username }} </span>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index f4991a41325..e540e7326fd 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -49,6 +49,11 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
renderDiffFile: {
type: Boolean,
required: false,
@@ -64,6 +69,11 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
@@ -81,6 +91,7 @@ export default {
'nextUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
'hasUnresolvedDiscussions',
+ 'showJumpToNextDiscussion',
]),
author() {
return this.initialDiscussion.author;
@@ -121,6 +132,12 @@ export default {
resolvedText() {
return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
+ shouldShowJumpToNextDiscussion() {
+ return this.showJumpToNextDiscussion(
+ this.discussion.id,
+ this.discussionsByDiffOrder ? 'diff' : 'discussion',
+ );
+ },
shouldRenderDiffs() {
return this.discussion.diff_discussion && this.renderDiffFile;
},
@@ -183,6 +200,13 @@ export default {
false,
);
},
+ diffLine() {
+ if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) {
+ return this.discussion.truncated_diff_lines.slice(-1)[0];
+ }
+
+ return this.line;
+ },
},
watch: {
isReplying() {
@@ -346,6 +370,8 @@ Please check your network connection and try again.`;
<component
:is="componentName(initialDiscussion)"
:note="componentData(initialDiscussion)"
+ :line="line"
+ :help-page-path="helpPagePath"
@handleDeleteNote="deleteNoteHandler"
>
<slot slot="avatar-badge" name="avatar-badge"></slot>
@@ -362,6 +388,8 @@ Please check your network connection and try again.`;
v-for="note in replies"
:key="note.id"
:note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
@handleDeleteNote="deleteNoteHandler"
/>
</template>
@@ -372,6 +400,8 @@ Please check your network connection and try again.`;
v-for="(note, index) in discussion.notes"
:key="note.id"
:note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="diffLine"
@handleDeleteNote="deleteNoteHandler"
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
@@ -379,7 +409,7 @@ Please check your network connection and try again.`;
</template>
</ul>
<div
- v-if="!isRepliesCollapsed"
+ v-if="!isRepliesCollapsed || !hasReplies"
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder"
>
@@ -418,7 +448,7 @@ Please check your network connection and try again.`;
<icon name="issue-new" />
</a>
</div>
- <div v-if="hasUnresolvedDiscussions" class="btn-group" role="group">
+ <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group">
<button
v-gl-tooltip
class="btn btn-default discussion-next-btn"
@@ -436,6 +466,7 @@ Please check your network connection and try again.`;
ref="noteForm"
:discussion="discussion"
:is-editing="false"
+ :line="diffLine"
save-button-title="Comment"
@handleFormUpdate="saveReply"
@cancelForm="cancelReplyForm"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index a17be51353e..57e9c40bd61 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -27,6 +27,16 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -220,8 +230,10 @@ export default {
<note-body
ref="noteBody"
:note="note"
+ :line="line"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
+ :help-page-path="helpPagePath"
@handleFormUpdate="formUpdateHandler"
@cancelForm="formCancelHandler"
/>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 0a87cd7ef1f..f3fcfdfda05 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
+import initUserPopovers from '../../user_popovers';
export default {
name: 'NotesApp',
@@ -48,6 +49,11 @@ export default {
required: false,
default: 0,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -106,7 +112,10 @@ export default {
}
},
updated() {
- this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')));
+ this.$nextTick(() => {
+ highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ });
},
methods: {
...mapActions([
@@ -202,6 +211,7 @@ export default {
:key="discussion.id"
:discussion="discussion"
:render-diff-file="true"
+ :help-page-path="helpPagePath"
/>
</template>
</ul>
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index f7c4deee1f8..3d89d907777 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,29 +1,56 @@
import { scrollToElement } from '~/lib/utils/common_utils';
+import eventHub from '../../notes/event_hub';
export default {
methods: {
- jumpToDiscussion(id) {
- if (id) {
- const activeTab = window.mrTabs.currentAction;
- const selector =
- activeTab === 'diffs'
- ? `ul.notes[data-discussion-id="${id}"]`
- : `div.discussion[data-discussion-id="${id}"]`;
- const el = document.querySelector(selector);
+ diffsJump(id) {
+ const selector = `ul.notes[data-discussion-id="${id}"]`;
- if (activeTab === 'commits' || activeTab === 'pipelines') {
- window.mrTabs.activateTab('show');
- }
+ eventHub.$once('scrollToDiscussion', () => {
+ const el = document.querySelector(selector);
if (el) {
- this.expandDiscussion({ discussionId: id });
-
scrollToElement(el);
+
return true;
}
+
+ return false;
+ });
+
+ this.expandDiscussion({ discussionId: id });
+ },
+ discussionJump(id) {
+ const selector = `div.discussion[data-discussion-id="${id}"]`;
+
+ const el = document.querySelector(selector);
+
+ this.expandDiscussion({ discussionId: id });
+
+ if (el) {
+ scrollToElement(el);
+
+ return true;
}
return false;
},
+ jumpToDiscussion(id) {
+ if (id) {
+ const activeTab = window.mrTabs.currentAction;
+
+ if (activeTab === 'diffs') {
+ this.diffsJump(id);
+ } else if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
+ setTimeout(() => this.discussionJump(id), 0);
+ });
+
+ window.mrTabs.tabShown('show');
+ } else {
+ this.discussionJump(id);
+ }
+ }
+ },
},
};
diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index 47a6f07cce2..237e70c0a4c 100644
--- a/app/assets/javascripts/notes/services/notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import Api from '~/api';
import VueResource from 'vue-resource';
import * as constants from '../constants';
@@ -44,4 +45,7 @@ export default {
toggleIssueState(endpoint, data) {
return Vue.http.put(endpoint, data);
},
+ applySuggestion(id) {
+ return Api.applySuggestion(id);
+ },
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index b4befdd6e4a..65f85314fa0 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -17,7 +17,13 @@ import { __ } from '~/locale';
let eTagPoll;
-export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data);
+export const expandDiscussion = ({ commit, dispatch }, data) => {
+ if (data.discussionId) {
+ dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true });
+ }
+
+ commit(types.EXPAND_DISCUSSION, data);
+};
export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data);
@@ -399,5 +405,25 @@ export const startTaskList = ({ dispatch }) =>
export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
+export const submitSuggestion = (
+ { commit },
+ { discussionId, noteId, suggestionId, flashContainer, callback },
+) => {
+ service
+ .applySuggestion(suggestionId)
+ .then(() => {
+ commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
+ callback();
+ })
+ .catch(() => {
+ Flash(
+ __('Something went wrong while applying the suggestion. Please try again.'),
+ 'alert',
+ flashContainer,
+ );
+ callback();
+ });
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 2ed8aac059a..0ffc0cb2593 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -57,6 +57,17 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo
export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
+export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => {
+ const orderedDiffs =
+ mode !== 'discussion'
+ ? getters.unresolvedDiscussionsIdsByDiff
+ : getters.unresolvedDiscussionsIdsByDate;
+
+ const indexOf = orderedDiffs.indexOf(discussionId);
+
+ return indexOf !== -1 && indexOf < orderedDiffs.length - 1;
+};
+
export const isDiscussionResolved = (state, getters) => discussionId =>
getters.resolvedDiscussionsById[discussionId] !== undefined;
@@ -104,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) =>
// line numbers.
export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
getters.allResolvableDiscussions
- .filter(d => !d.resolved)
+ .filter(d => !d.resolved && d.active)
.sort((a, b) => {
if (!a.diff_file || !b.diff_file) {
return 0;
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index b5fe8bdb1d3..887e6d22b06 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -20,6 +20,7 @@ export default () => ({
userData: {},
noteableData: {
current_user: {},
+ preview_note_path: 'path/to/preview',
},
commentsDisabled: false,
resolvableDiscussionsCount: 0,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 9c68ab67a8c..df943c155f4 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -16,6 +16,7 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES';
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';
// 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 dfce698e56f..8992454be2e 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -22,6 +22,7 @@ export default {
if (isDiscussion && isInMRPage()) {
noteData.resolvable = note.resolvable;
noteData.resolved = false;
+ noteData.active = true;
noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
noteData.diff_discussion = false;
@@ -196,6 +197,17 @@ export default {
}
},
+ [types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) {
+ const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
+ const comment = utils.findNoteObjectById(noteObj.notes, noteId);
+
+ comment.suggestions = comment.suggestions.map(suggestion => ({
+ ...suggestion,
+ applied: suggestion.applied || suggestion.id === suggestionId,
+ appliable: false,
+ }));
+ },
+
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index 6233fb169e9..9af5660f764 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -1,15 +1,13 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
-import Flash from '../../flash';
import store from '../stores';
-import collapsibleContainer from './collapsible_container.vue';
-import { errorMessages, errorMessagesTypes } from '../constants';
+import CollapsibleContainer from './collapsible_container.vue';
export default {
name: 'RegistryListApp',
components: {
- collapsibleContainer,
+ CollapsibleContainer,
GlLoadingIcon,
},
props: {
@@ -26,7 +24,7 @@ export default {
this.setMainEndpoint(this.endpoint);
},
mounted() {
- this.fetchRepos().catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS]));
+ this.fetchRepos();
},
methods: {
...mapActions(['setMainEndpoint', 'fetchRepos']),
@@ -38,9 +36,9 @@ export default {
<gl-loading-icon v-if="isLoading" :size="3" />
<collapsible-container
- v-for="(item, index) in repos"
+ v-for="item in repos"
v-else-if="!isLoading && repos.length"
- :key="index"
+ :key="item.id"
:repo="item"
/>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index 6514c05a9c7..5451c61026c 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -1,22 +1,24 @@
<script>
import { mapActions } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
-import Flash from '../../flash';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
-import tableRegistry from './table_registry.vue';
+import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import createFlash from '../../flash';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import Icon from '../../vue_shared/components/icon.vue';
+import TableRegistry from './table_registry.vue';
import { errorMessages, errorMessagesTypes } from '../constants';
import { __ } from '../../locale';
export default {
name: 'CollapsibeContainerRegisty',
components: {
- clipboardButton,
- tableRegistry,
+ ClipboardButton,
+ TableRegistry,
GlLoadingIcon,
+ GlButton,
+ Icon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
repo: {
@@ -29,30 +31,30 @@ export default {
isOpen: false,
};
},
+ computed: {
+ iconName() {
+ return this.isOpen ? 'angle-up' : 'angle-right';
+ },
+ },
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteRepo']),
-
toggleRepo() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
- this.fetchList({ repo: this.repo }).catch(() =>
- this.showError(errorMessagesTypes.FETCH_REGISTRY),
- );
+ this.fetchList({ repo: this.repo });
}
},
-
handleDeleteRepository() {
this.deleteRepo(this.repo)
.then(() => {
- Flash(__('This container registry has been scheduled for deletion.'), 'notice');
+ createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
})
.catch(() => this.showError(errorMessagesTypes.DELETE_REPO));
},
-
showError(message) {
- Flash(errorMessages[message]);
+ createFlash(errorMessages[message]);
},
},
};
@@ -61,18 +63,9 @@ export default {
<template>
<div class="container-image">
<div class="container-image-head">
- <button type="button" class="js-toggle-repo btn-link" @click="toggleRepo">
- <i
- :class="{
- 'fa-chevron-right': !isOpen,
- 'fa-chevron-up': isOpen,
- }"
- class="fa"
- aria-hidden="true"
- >
- </i>
- {{ repo.name }}
- </button>
+ <gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
+ <icon :name="iconName" /> {{ repo.name }}
+ </gl-button>
<clipboard-button
v-if="repo.location"
@@ -82,17 +75,17 @@ export default {
/>
<div class="controls d-none d-sm-block float-right">
- <button
+ <gl-button
v-if="repo.canDelete"
- v-tooltip
+ v-gl-tooltip
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
- type="button"
- class="js-remove-repo btn btn-danger"
+ class="js-remove-repo"
+ variant="danger"
@click="handleDeleteRepository"
>
- <i class="fa fa-trash" aria-hidden="true"> </i>
- </button>
+ <icon name="remove" />
+ </gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 6735c3ff7cf..78c7671856a 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,21 +1,24 @@
<script>
import { mapActions } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '../../locale';
-import Flash from '../../flash';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import tablePagination from '../../vue_shared/components/table_pagination.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+import createFlash from '../../flash';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import TablePagination from '../../vue_shared/components/table_pagination.vue';
+import Icon from '../../vue_shared/components/icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import { errorMessages, errorMessagesTypes } from '../constants';
import { numberToHumanSize } from '../../lib/utils/number_utils';
export default {
components: {
- clipboardButton,
- tablePagination,
+ ClipboardButton,
+ TablePagination,
+ GlButton,
+ Icon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
@@ -31,29 +34,24 @@ export default {
},
methods: {
...mapActions(['fetchList', 'deleteRegistry']),
-
layers(item) {
return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
},
-
formatSize(size) {
return numberToHumanSize(size);
},
-
handleDeleteRegistry(registry) {
this.deleteRegistry(registry)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
},
-
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
this.showError(errorMessagesTypes.FETCH_REGISTRY),
);
},
-
showError(message) {
- Flash(errorMessages[message]);
+ createFlash(errorMessages[message]);
},
},
};
@@ -71,10 +69,9 @@ export default {
</tr>
</thead>
<tbody>
- <tr v-for="(item, i) in repo.list" :key="i">
+ <tr v-for="item in repo.list" :key="item.tag">
<td>
{{ item.tag }}
-
<clipboard-button
v-if="item.location"
:title="item.location"
@@ -83,37 +80,34 @@ export default {
/>
</td>
<td>
- <span v-tooltip :title="item.revision" data-placement="bottom">
- {{ item.shortRevision }}
- </span>
+ <span v-gl-tooltip.bottom :title="item.revision">{{ item.shortRevision }}</span>
</td>
<td>
{{ formatSize(item.size) }}
- <template v-if="item.size && item.layers">
- &middot;
- </template>
+ <template v-if="item.size && item.layers"
+ >&middot;</template
+ >
{{ layers(item) }}
</td>
<td>
- <span v-tooltip :title="tooltipTitle(item.createdAt)" data-placement="bottom">
- {{ timeFormated(item.createdAt) }}
- </span>
+ <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
+ timeFormated(item.createdAt)
+ }}</span>
</td>
<td class="content">
- <button
+ <gl-button
v-if="item.canDelete"
- v-tooltip
+ v-gl-tooltip
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
- type="button"
- class="js-delete-registry btn btn-danger d-none d-sm-block float-right"
- data-container="body"
+ variant="danger"
+ class="js-delete-registry d-none d-sm-block float-right"
@click="handleDeleteRegistry(item);"
>
- <i class="fa fa-trash" aria-hidden="true"> </i>
- </button>
+ <icon name="remove" />
+ </gl-button>
</td>
</tr>
</tbody>
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index a78aa90b7b5..51d057c62c1 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -1,39 +1,45 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
import * as types from './mutation_types';
-
-Vue.use(VueResource);
+import { errorMessages, errorMessagesTypes } from '../constants';
export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING);
- return Vue.http
+ return axios
.get(state.endpoint)
- .then(res => res.json())
- .then(response => {
+ .then(({ data }) => {
+ commit(types.TOGGLE_MAIN_LOADING);
+ commit(types.SET_REPOS_LIST, data);
+ })
+ .catch(() => {
commit(types.TOGGLE_MAIN_LOADING);
- commit(types.SET_REPOS_LIST, response);
+ createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]);
});
};
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
- return Vue.http.get(repo.tagsPath, { params: { page } }).then(response => {
- const { headers } = response;
+ return axios
+ .get(repo.tagsPath, { params: { page } })
+ .then(response => {
+ const { headers, data } = response;
- return response.json().then(resp => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
- commit(types.SET_REGISTRY_LIST, { repo, resp, headers });
+ commit(types.SET_REGISTRY_LIST, { repo, resp: data, headers });
+ })
+ .catch(() => {
+ commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
+ createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]);
});
- });
};
// eslint-disable-next-line no-unused-vars
-export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath);
+export const deleteRepo = ({ commit }, repo) => axios.delete(repo.destroyPath);
// eslint-disable-next-line no-unused-vars
-export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath);
+export const deleteRegistry = ({ commit }, image) => axios.delete(image.destroyPath);
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js
index 78b67881210..1bb06bd6e81 100644
--- a/app/assets/javascripts/registry/stores/index.js
+++ b/app/assets/javascripts/registry/stores/index.js
@@ -3,36 +3,12 @@ import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
+import createState from './state';
Vue.use(Vuex);
export default new Vuex.Store({
- state: {
- isLoading: false,
- endpoint: '', // initial endpoint to fetch the repos list
- /**
- * Each object in `repos` has the following strucure:
- * {
- * name: String,
- * isLoading: Boolean,
- * tagsPath: String // endpoint to request the list
- * destroyPath: String // endpoit to delete the repo
- * list: Array // List of the registry images
- * }
- *
- * Each registry image inside `list` has the following structure:
- * {
- * tag: String,
- * revision: String
- * shortRevision: String
- * size: Number
- * layers: Number
- * createdAt: String
- * destroyPath: String // endpoit to delete each image
- * }
- */
- repos: [],
- },
+ state: createState(),
actions,
getters,
mutations,
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index 69c051cd2d6..1ac699c538f 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -48,6 +48,7 @@ export default {
[types.TOGGLE_REGISTRY_LIST_LOADING](state, list) {
const listToUpdate = state.repos.find(el => el.id === list.id);
+
listToUpdate.isLoading = !listToUpdate.isLoading;
},
};
diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js
new file mode 100644
index 00000000000..feeac10cbe1
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/state.js
@@ -0,0 +1,26 @@
+export default () => ({
+ isLoading: false,
+ endpoint: '', // initial endpoint to fetch the repos list
+ /**
+ * Each object in `repos` has the following strucure:
+ * {
+ * name: String,
+ * isLoading: Boolean,
+ * tagsPath: String // endpoint to request the list
+ * destroyPath: String // endpoit to delete the repo
+ * list: Array // List of the registry images
+ * }
+ *
+ * Each registry image inside `list` has the following structure:
+ * {
+ * tag: String,
+ * revision: String
+ * shortRevision: String
+ * size: Number
+ * layers: Number
+ * createdAt: String
+ * destroyPath: String // endpoit to delete each image
+ * }
+ */
+ repos: [],
+});
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
new file mode 100644
index 00000000000..948f4d5e631
--- /dev/null
+++ b/app/assets/javascripts/user_popovers.js
@@ -0,0 +1,107 @@
+import Vue from 'vue';
+
+import UsersCache from './lib/utils/users_cache';
+import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
+
+let renderedPopover;
+let renderFn;
+
+const handleUserPopoverMouseOut = event => {
+ const { target } = event;
+ target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ if (renderFn) {
+ clearTimeout(renderFn);
+ }
+ if (renderedPopover) {
+ renderedPopover.$destroy();
+ renderedPopover = null;
+ }
+};
+
+/**
+ * Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes.
+ * loads based on data-user-id more data about a user from the API and sets it on the popover
+ */
+const handleUserPopoverMouseOver = event => {
+ const { target } = event;
+ // Add listener to actually remove it again
+ target.addEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ renderFn = setTimeout(() => {
+ // Helps us to use current markdown setup without maybe breaking or duplicating for now
+ if (target.dataset.user) {
+ target.dataset.userId = target.dataset.user;
+ // Removing titles so its not showing tooltips also
+ target.dataset.originalTitle = '';
+ target.setAttribute('title', '');
+ }
+
+ const { userId, username, name, avatarUrl } = target.dataset;
+ const user = {
+ userId,
+ username,
+ name,
+ avatarUrl,
+ location: null,
+ bio: null,
+ organization: null,
+ status: null,
+ loaded: false,
+ };
+ if (userId || username) {
+ const UserPopoverComponent = Vue.extend(UserPopover);
+ renderedPopover = new UserPopoverComponent({
+ propsData: {
+ target,
+ user,
+ },
+ });
+
+ renderedPopover.$mount();
+
+ UsersCache.retrieveById(userId)
+ .then(userData => {
+ if (!userData) {
+ return;
+ }
+
+ Object.assign(user, {
+ avatarUrl: userData.avatar_url,
+ username: userData.username,
+ name: userData.name,
+ location: userData.location,
+ bio: userData.bio,
+ organization: userData.organization,
+ loaded: true,
+ });
+
+ UsersCache.retrieveStatusById(userId)
+ .then(status => {
+ if (!status) {
+ return;
+ }
+
+ Object.assign(user, {
+ status,
+ });
+ })
+ .catch(() => {
+ throw new Error(`User status for "${userId}" could not be retrieved!`);
+ });
+ })
+ .catch(() => {
+ renderedPopover.$destroy();
+ renderedPopover = null;
+ });
+ }
+ }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
+};
+
+export default elements => {
+ const userLinks = elements || [...document.querySelectorAll('.js-user-link')];
+
+ userLinks.forEach(el => {
+ el.addEventListener('mouseenter', handleUserPopoverMouseOver);
+ });
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
index e3adc7f7af5..4b57693e8f1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -13,5 +13,7 @@ export default {
</script>
<template>
- <div class="circle-icon-container append-right-default"><icon :name="name" /></div>
+ <div class="circle-icon-container append-right-default align-self-start align-self-lg-center">
+ <icon :name="name" />
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
new file mode 100644
index 00000000000..7e79e63aa1e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -0,0 +1,94 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ components: {
+ UserAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ assignees: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ maxVisibleAssignees: 2,
+ maxAssigneeAvatars: 3,
+ maxAssignees: 99,
+ };
+ },
+ computed: {
+ countOverLimit() {
+ return this.assignees.length - this.maxVisibleAssignees;
+ },
+ assigneesToShow() {
+ if (this.assignees.length > this.maxAssigneeAvatars) {
+ return this.assignees.slice(0, this.maxVisibleAssignees);
+ }
+ return this.assignees;
+ },
+ assigneesCounterTooltip() {
+ const { countOverLimit, maxAssignees } = this;
+ const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
+
+ return sprintf(__('%{count} more assignees'), { count });
+ },
+ shouldRenderAssigneesCounter() {
+ const assigneesCount = this.assignees.length;
+ if (assigneesCount <= this.maxAssigneeAvatars) {
+ return false;
+ }
+
+ return assigneesCount > this.countOverLimit;
+ },
+ assigneeCounterLabel() {
+ if (this.countOverLimit > this.maxAssignees) {
+ return `${this.maxAssignees}+`;
+ }
+
+ return `+${this.countOverLimit}`;
+ },
+ },
+ methods: {
+ avatarUrlTitle(assignee) {
+ return sprintf(__('Avatar for %{assigneeName}'), {
+ assigneeName: assignee.name,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="issue-assignees">
+ <user-avatar-link
+ v-for="assignee in assigneesToShow"
+ :key="assignee.id"
+ :link-href="assignee.web_url"
+ :img-alt="avatarUrlTitle(assignee)"
+ :img-src="assignee.avatar_url"
+ :img-size="24"
+ class="js-no-trigger"
+ tooltip-placement="bottom"
+ >
+ <span class="js-assignee-tooltip">
+ <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
+ <span class="text-white-50">@{{ assignee.username }}</span>
+ </span>
+ </user-avatar-link>
+ <span
+ v-if="shouldRenderAssigneesCounter"
+ v-gl-tooltip
+ :title="assigneesCounterTooltip"
+ class="avatar-counter"
+ data-placement="bottom"
+ >{{ assigneeCounterLabel }}</span
+ >
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
new file mode 100644
index 00000000000..d5d967e25bf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlTooltip } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlTooltip,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ milestone: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null,
+ milestoneStart: this.milestone.start_date
+ ? parsePikadayDate(this.milestone.start_date)
+ : null,
+ };
+ },
+ computed: {
+ isMilestoneStarted() {
+ if (!this.milestoneStart) {
+ return false;
+ }
+ return Date.now() > this.milestoneStart;
+ },
+ isMilestonePastDue() {
+ if (!this.milestoneDue) {
+ return false;
+ }
+ return Date.now() > this.milestoneDue;
+ },
+ milestoneDatesAbsolute() {
+ if (this.milestoneDue) {
+ return `(${dateInWords(this.milestoneDue)})`;
+ } else if (this.milestoneStart) {
+ return `(${dateInWords(this.milestoneStart)})`;
+ }
+ return '';
+ },
+ milestoneDatesHuman() {
+ if (this.milestoneStart || this.milestoneDue) {
+ if (this.milestoneDue) {
+ return timeFor(
+ this.milestoneDue,
+ sprintf(__('Expired %{expiredOn}'), {
+ expiredOn: this.timeFormated(this.milestoneDue),
+ }),
+ );
+ }
+
+ return sprintf(
+ this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
+ {
+ startsIn: this.timeFormated(this.milestoneStart),
+ },
+ );
+ }
+ return '';
+ },
+ },
+};
+</script>
+<template>
+ <div ref="milestoneDetails" class="issue-milestone-details">
+ <icon :size="16" class="inline icon" name="clock" />
+ <span class="milestone-title">{{ milestone.title }}</span>
+ <gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
+ <span class="bold">{{ __('Milestone') }}</span> <br />
+ <span>{{ milestone.title }}</span> <br />
+ <span
+ v-if="milestoneStart || milestoneDue"
+ :class="{
+ 'text-danger-muted': isMilestonePastDue,
+ 'text-tertiary': !isMilestonePastDue,
+ }"
+ ><span>{{ milestoneDatesHuman }}</span
+ ><br /><span>{{ milestoneDatesAbsolute }}</span>
+ </span>
+ </gl-tooltip>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 43def2673eb..2f7ed4a982c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,17 +1,21 @@
<script>
import $ from 'jquery';
+import _ from 'underscore';
import { __ } from '~/locale';
+import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
+import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
markdownHeader,
markdownToolbar,
icon,
+ Suggestions,
},
props: {
markdownPreviewPath: {
@@ -48,12 +52,33 @@ export default {
required: false,
default: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ note: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ canSuggest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
+ hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
};
@@ -63,6 +88,39 @@ export default {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
+ lineContent() {
+ const FIRST_CHAR_REGEX = /^(\+|-)/;
+ const [firstSuggestion] = this.suggestions;
+ if (firstSuggestion) {
+ return firstSuggestion.from_content;
+ }
+
+ if (this.line) {
+ const { rich_text: richText, text } = this.line;
+
+ if (text) {
+ return text.replace(FIRST_CHAR_REGEX, '');
+ }
+
+ return _.unescape(stripHtml(richText).replace(/\n/g, ''));
+ }
+
+ return '';
+ },
+ lineNumber() {
+ let lineNumber;
+ if (this.line) {
+ const { new_line: newLine, old_line: oldLine } = this.line;
+ lineNumber = newLine || oldLine;
+ }
+ return lineNumber;
+ },
+ suggestions() {
+ return this.note.suggestions || [];
+ },
+ lineType() {
+ return this.line ? this.line.type : '';
+ },
},
mounted() {
/*
@@ -122,6 +180,7 @@ export default {
if (data.references) {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
+ this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
}
this.$nextTick(() => {
@@ -147,6 +206,8 @@ export default {
>
<markdown-header
:preview-markdown="previewMarkdown"
+ :line-content="lineContent"
+ :can-suggest="canSuggest"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
/>
@@ -163,19 +224,39 @@ export default {
/>
</div>
</div>
- <div
- v-show="previewMarkdown"
- ref="markdown-preview"
- class="md-preview js-vue-md-preview md md-preview-holder"
- v-html="markdownPreview"
- ></div>
+ <template v-if="hasSuggestion">
+ <div
+ v-show="previewMarkdown"
+ ref="markdown-preview"
+ class="md-preview js-vue-md-preview md md-preview-holder"
+ >
+ <suggestions
+ v-if="hasSuggestion"
+ :note-html="markdownPreview"
+ :from-line="lineNumber"
+ :from-content="lineContent"
+ :line-type="lineType"
+ :disabled="true"
+ :suggestions="suggestions"
+ :help-page-path="helpPagePath"
+ />
+ </div>
+ </template>
+ <template v-else>
+ <div
+ v-show="previewMarkdown"
+ ref="markdown-preview"
+ class="md-preview js-vue-md-preview md md-preview-holder"
+ v-html="markdownPreview"
+ ></div>
+ </template>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<span>
- <i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add
+ <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add
<strong>
- <span class="js-referenced-users-count"> {{ referencedUsers.length }} </span>
+ <span class="js-referenced-users-count">{{ referencedUsers.length }}</span>
</strong>
people to the discussion. Proceed with caution.
</span>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 4c4ba537065..bf4d42670ee 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -17,6 +17,16 @@ export default {
type: Boolean,
required: true,
},
+ lineContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canSuggest: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
mdTable() {
@@ -27,6 +37,9 @@ export default {
'| cell | cell |',
].join('\n');
},
+ mdSuggestion() {
+ return ['```suggestion', `{text}`, '```'].join('\n');
+ },
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
@@ -119,6 +132,16 @@ export default {
:button-title="__('Add a table')"
icon="table"
/>
+ <toolbar-button
+ v-if="canSuggest"
+ :tag="mdSuggestion"
+ :prepend="true"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ class="qa-suggestion-btn"
+ />
<button
v-gl-tooltip
aria-label="Go full screen"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
new file mode 100644
index 00000000000..f98560f7336
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -0,0 +1,74 @@
+<script>
+import SuggestionDiffHeader from './suggestion_diff_header.vue';
+
+export default {
+ components: {
+ SuggestionDiffHeader,
+ },
+ props: {
+ newLines: {
+ type: Array,
+ required: true,
+ },
+ fromContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fromLine: {
+ type: Number,
+ required: true,
+ },
+ suggestion: {
+ type: Object,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ applySuggestion(callback) {
+ this.$emit('apply', { suggestionId: this.suggestion.id, callback });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <suggestion-diff-header
+ class="qa-suggestion-diff-header"
+ :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
+ :is-applied="suggestion.applied"
+ :help-page-path="helpPagePath"
+ @apply="applySuggestion"
+ />
+ <table class="mb-3 md-suggestion-diff">
+ <tbody>
+ <!-- Old Line -->
+ <tr class="line_holder old">
+ <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td>
+ <td class="diff-line-num new_line old"></td>
+ <td class="line_content old">
+ <span>{{ fromContent }}</span>
+ </td>
+ </tr>
+ <!-- New Line(s) -->
+ <tr v-for="(line, key) of newLines" :key="key" class="line_holder new">
+ <td class="diff-line-num old_line new"></td>
+ <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td>
+ <td class="line_content new">
+ <span>{{ line.content }}</span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
new file mode 100644
index 00000000000..563e2f94fcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -0,0 +1,60 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: { Icon },
+ props: {
+ canApply: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isApplied: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isAppliedSuccessfully: false,
+ isApplying: false,
+ };
+ },
+ methods: {
+ applySuggestion() {
+ if (!this.canApply) return;
+ this.isApplying = true;
+ this.$emit('apply', this.applySuggestionCallback);
+ },
+ applySuggestionCallback() {
+ this.isApplying = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="md-suggestion-header border-bottom-0 mt-2">
+ <div class="qa-suggestion-diff-header font-weight-bold">
+ {{ __('Suggested change') }}
+ <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')">
+ <icon name="question-o" css-classes="link-highlight" />
+ </a>
+ </div>
+ <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
+ <button
+ v-if="canApply"
+ type="button"
+ class="btn qa-apply-btn"
+ :disabled="isApplying"
+ @click="applySuggestion"
+ >
+ {{ __('Apply suggestion') }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
new file mode 100644
index 00000000000..7c6dbee3e19
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -0,0 +1,136 @@
+<script>
+import Vue from 'vue';
+import SuggestionDiff from './suggestion_diff.vue';
+import Flash from '~/flash';
+
+export default {
+ components: { SuggestionDiff },
+ props: {
+ fromLine: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ fromContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lineType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ suggestions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ noteHtml: {
+ type: String,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isRendered: false,
+ };
+ },
+ watch: {
+ suggestions() {
+ this.reset();
+ },
+ noteHtml() {
+ this.reset();
+ },
+ },
+ mounted() {
+ this.renderSuggestions();
+ },
+ methods: {
+ renderSuggestions() {
+ // swaps out suggestion(s) markdown with rich diff components
+ // (while still keeping non-suggestion markdown in place)
+
+ if (!this.noteHtml) return;
+ const { container } = this.$refs;
+ const suggestionElements = container.querySelectorAll('.js-render-suggestion');
+
+ if (this.lineType === 'old') {
+ Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el);
+ }
+
+ suggestionElements.forEach((suggestionEl, i) => {
+ const suggestionParentEl = suggestionEl.parentElement;
+ const newLines = this.extractNewLines(suggestionParentEl);
+ const diffComponent = this.generateDiff(newLines, i);
+ diffComponent.$mount(suggestionParentEl);
+ });
+
+ this.isRendered = true;
+ },
+ extractNewLines(suggestionEl) {
+ // extracts the suggested lines from the markdown
+ // calculates a line number for each line
+
+ const FIRST_CHAR_REGEX = /^(\+|-)/;
+ const newLines = suggestionEl.querySelectorAll('.line');
+ const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine;
+ const lines = [];
+
+ newLines.forEach((line, i) => {
+ const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`;
+ const lineNumber = fromLine + i;
+ lines.push({ content, lineNumber });
+ });
+
+ return lines;
+ },
+ generateDiff(newLines, suggestionIndex) {
+ // generates the diff <suggestion-diff /> component
+ // all `suggestion` markdown will be swapped out by this component
+
+ const { suggestions, disabled, helpPagePath } = this;
+ const suggestion =
+ suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
+ const fromContent = suggestion.from_content || this.fromContent;
+ const fromLine = suggestion.from_line || this.fromLine;
+ const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
+ const suggestionDiff = new SuggestionDiffComponent({
+ propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath },
+ });
+
+ suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
+ this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
+ });
+
+ return suggestionDiff;
+ },
+ reset() {
+ // resets the container HTML (replaces it with the updated noteHTML)
+ // calls `renderSuggestions` once the updated noteHTML is added to the DOM
+
+ this.$refs.container.innerHTML = this.noteHtml;
+ this.isRendered = false;
+ this.renderSuggestions();
+ this.$nextTick(() => this.renderSuggestions());
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="flash-container mt-3"></div>
+ <div v-show="isRendered" ref="container" v-html="noteHtml"></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index a6d2cecdf7e..4572caa907b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -37,6 +37,16 @@ export default {
required: false,
default: false,
},
+ tagContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ cursorOffset: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
};
</script>
@@ -45,8 +55,10 @@ export default {
<button
v-gl-tooltip
:data-md-tag="tag"
+ :data-md-cursor-offset="cursorOffset"
:data-md-select="tagSelect"
:data-md-block="tagBlock"
+ :data-md-tag-content="tagContent"
:data-md-prepend="prepend"
:title="buttonTitle"
:aria-label="buttonTitle"
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index 01b8b94f9e3..e833a8e0483 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -67,7 +67,7 @@ export default {
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
+ if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`;
return baseSrc;
},
resultantSrcAttribute() {
@@ -97,6 +97,7 @@ export default {
class="avatar"
/>
<gl-tooltip
+ v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
new file mode 100644
index 00000000000..7fbadcc0111
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
+import { glEmojiTag } from '../../../emoji';
+
+export default {
+ name: 'UserPopover',
+ components: {
+ GlPopover,
+ GlSkeletonLoading,
+ UserAvatarImage,
+ },
+ props: {
+ target: {
+ type: HTMLAnchorElement,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: true,
+ default: null,
+ },
+ loaded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ jobLine() {
+ if (this.user.bio && this.user.organization) {
+ return sprintf(__('%{bio} at %{organization}'), {
+ bio: this.user.bio,
+ organization: this.user.organization,
+ });
+ } else if (this.user.bio) {
+ return this.user.bio;
+ } else if (this.user.organization) {
+ return this.user.organization;
+ }
+ return null;
+ },
+ statusHtml() {
+ if (this.user.status.emoji && this.user.status.message) {
+ return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`;
+ } else if (this.user.status.message) {
+ return this.user.status.message;
+ }
+ return '';
+ },
+ nameIsLoading() {
+ return !this.user.name;
+ },
+ jobInfoIsLoading() {
+ return !this.user.loaded && this.user.organization === null;
+ },
+ locationIsLoading() {
+ return !this.user.loaded && this.user.location === null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover :target="target" boundary="viewport" placement="top" show>
+ <div class="user-popover d-flex">
+ <div class="p-1 flex-shrink-1">
+ <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
+ </div>
+ <div class="p-1 w-100">
+ <h5 class="m-0">
+ {{ user.name }}
+ <gl-skeleton-loading
+ v-if="nameIsLoading"
+ :lines="1"
+ class="animation-container-small mb-1"
+ />
+ </h5>
+ <div class="text-secondary mb-2">
+ <span v-if="user.username">@{{ user.username }}</span>
+ <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
+ </div>
+ <div class="text-secondary">
+ {{ jobLine }}
+ <gl-skeleton-loading
+ v-if="jobInfoIsLoading"
+ :lines="1"
+ class="animation-container-small mb-1"
+ />
+ </div>
+ <div class="text-secondary">
+ {{ user.location }}
+ <gl-skeleton-loading
+ v-if="locationIsLoading"
+ :lines="1"
+ class="animation-container-small mb-1"
+ />
+ </div>
+ <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div>
+ </div>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index bd1cca69c03..985fac11c87 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -35,6 +35,11 @@
@import "pages/**/*";
/*
+ * Component specific styles, will be moved to gitlab-ui
+ */
+@import "components/**/*";
+
+/*
* Code highlight
*/
@import "highlight/dark";
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index 62024b8c555..f0671e36130 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -18,8 +18,10 @@ $input-border: $border-color;
$padding-base-vertical: $gl-vert-padding;
$padding-base-horizontal: $gl-padding;
-html {
- // Override default font size used in bs4
+body,
+.form-control,
+.search form {
+ // Override default font size used in non-csslab UI
font-size: 14px;
}
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
new file mode 100644
index 00000000000..2f4d30fe923
--- /dev/null
+++ b/app/assets/stylesheets/components/popover.scss
@@ -0,0 +1,9 @@
+.popover {
+ min-width: 300px;
+
+ .popover-body .user-popover {
+ padding: $gl-padding-8;
+ font-size: $gl-font-size-small;
+ line-height: $gl-line-height;
+ }
+}
diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss
new file mode 100644
index 00000000000..acaa41e2677
--- /dev/null
+++ b/app/assets/stylesheets/csslab.scss
@@ -0,0 +1 @@
+@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index e36f99ac577..a4a9276c580 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -148,8 +148,8 @@
&.btn-xs {
padding: 2px $gl-btn-padding;
- font-size: $gl-btn-small-font-size;
- line-height: $gl-btn-small-line-height;
+ font-size: $gl-btn-xs-font-size;
+ line-height: $gl-btn-xs-line-height;
}
&.btn-success,
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index f3c44f32d6f..f273eb9533d 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -176,9 +176,9 @@
display: block;
font-weight: $gl-font-weight-normal;
position: relative;
- padding: 8px 16px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
color: $gl-text-color;
- line-height: normal;
+ line-height: $gl-btn-line-height;
white-space: normal;
overflow: hidden;
text-align: left;
@@ -319,8 +319,8 @@
.dropdown-header {
color: $gl-text-color-secondary;
font-size: 13px;
- line-height: 22px;
- padding: 8px 16px;
+ line-height: $gl-line-height;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
&.capitalize-header .dropdown-header {
@@ -329,13 +329,8 @@
.dropdown-bold-header {
font-weight: $gl-font-weight-bold;
- line-height: 22px;
- padding: 0 16px;
- }
-
- .separator + .dropdown-header,
- .separator + .dropdown-bold-header {
- padding-top: 10px;
+ line-height: $gl-line-height;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
.unclickable {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 037a5adfb7e..3ac7b6b704b 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -24,7 +24,7 @@
}
}
- table {
+ &:not(.use-csslab) table {
@extend .table;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index c0cda29e239..45a52d99302 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -90,12 +90,6 @@
padding: 2px 8px;
margin: 5px 2px 5px -8px;
border-radius: $border-radius-default;
-
- .tanuki-logo {
- @include media-breakpoint-up(sm) {
- margin-right: 8px;
- }
- }
}
.project-item-select {
@@ -127,12 +121,6 @@
}
}
- li.dropdown-bold-header {
- color: $gl-text-color-secondary;
- font-size: 12px;
- padding: 0 16px;
- }
-
.navbar-collapse {
flex: 0 0 auto;
border-top: 0;
@@ -541,7 +529,7 @@
left: auto;
li.current-user {
- padding: 5px 18px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
.user-name {
display: block;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 0f6fb16774c..5609a2086e6 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -131,7 +131,7 @@
width: 100%;
}
-.md {
+.md:not(.use-csslab) {
&.md-preview-holder {
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
@@ -277,6 +277,27 @@
}
}
+.md-suggestion-diff {
+ display: table !important;
+ border: 1px solid $border-color !important;
+}
+
+.md-suggestion-header {
+ height: $suggestion-header-height;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: $gray-light;
+ border: 1px solid $border-color;
+ padding: $gl-padding;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+
+ svg {
+ vertical-align: middle;
+ margin-bottom: 3px;
+ }
+}
+
@include media-breakpoint-down(xs) {
.atwho-view-ul {
width: 350px;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index b3b99df5790..0c81dc2e156 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -368,11 +368,11 @@ code {
* Apply Markdown typography
*
*/
-.wiki {
+.wiki:not(.use-csslab) {
@include md-typography;
}
-.md {
+.md:not(.use-csslab) {
@include md-typography;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 134b3a4521b..4449193c104 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21;
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
+$shadow-color: rgba($black, 0.1);
$almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor);
@@ -250,6 +251,7 @@ $browserScrollbarSize: 10px;
* Misc
*/
$header-height: 40px;
+$suggestion-header-height: 46px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
@@ -368,7 +370,9 @@ $gl-btn-line-height: 16px;
$gl-btn-vert-padding: 8px;
$gl-btn-horz-padding: 12px;
$gl-btn-small-font-size: 13px;
-$gl-btn-small-line-height: 13px;
+$gl-btn-small-line-height: 18px;
+$gl-btn-xs-font-size: 13px;
+$gl-btn-xs-line-height: 13px;
/*
* Badges
@@ -399,7 +403,7 @@ $award-emoji-positive-add-lines: #bb9c13;
* Search Box
*/
$search-input-border-color: rgba($blue-400, 0.8);
-$search-input-width: 240px;
+$search-input-width: 200px;
$search-input-active-width: 320px;
$location-icon-color: #e7e9ed;
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index fab1b361f14..5ca76bb6c5a 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -21,3 +21,10 @@ $danger: $red-500;
$zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2);
$dropdown-divider-bg: $theme-gray-200;
+$dropdown-item-padding-y: 8px;
+$dropdown-item-padding-x: 12px;
+$popover-max-width: 300px;
+$popover-border-width: 1px;
+$popover-border-color: $border-color;
+$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color;
+$popover-arrow-outer-color: $shadow-color;
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 5405f20a760..18c62cb4f1e 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -914,6 +914,7 @@
padding: 0;
width: (2px * $image-comment-cursor-left-offset);
height: (2px * $image-comment-cursor-top-offset);
+ color: $blue-400;
// center the indicator to match the top left click region
margin-top: (-1px * $image-comment-cursor-top-offset) + 2;
margin-left: (-1px * $image-comment-cursor-left-offset) + 1;
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 132f3fea92b..a4831b64344 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -98,7 +98,6 @@
// Limits the width of the user bio for readability.
max-width: 600px;
margin: 10px auto;
- padding: 0 16px;
}
.user-avatar-button {
@@ -222,7 +221,11 @@
}
.profile-header {
- margin: 0 auto;
+ margin: 0 $gl-padding;
+
+ &.with-no-profile-tabs {
+ margin-bottom: $gl-padding-24;
+ }
.avatar-holder {
width: 90px;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 04151b1cd59..149c3254d84 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -101,8 +101,6 @@ input[type='checkbox']:hover {
.dropdown-header {
// Necessary because glDropdown doesn't support a second style of headers
font-weight: $gl-font-weight-bold;
- // .dropdown-menu li has 1px side padding
- padding: $gl-padding-8 17px;
color: $gl-text-color;
font-size: $gl-font-size;
line-height: 16px;
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 800f5c68e39..82e887aa62a 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list {
}
}
-.wiki {
+.wiki:not(.use-csslab) {
table {
@include markdown-table;
}
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index a597996a362..789e0dc736e 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -126,6 +126,8 @@ module IssuableCollections
sort_param = params[:sort]
sort_param ||= user_preference[issuable_sorting_field]
+ return sort_param if Gitlab::Database.read_only?
+
if user_preference[issuable_sorting_field] != sort_param
user_preference.update_attribute(issuable_sorting_field, sort_param)
end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index c61b9fabe9e..4b0f0b8255c 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -12,7 +12,7 @@ module PreviewMarkdown
when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
when 'snippets' then { skip_project_check: true }
when 'groups' then { group: group }
- when 'projects' then { issuable_state_filter_enabled: true }
+ when 'projects' then projects_filter_params
else {}
end
@@ -22,9 +22,17 @@ module PreviewMarkdown
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
+ suggestions: result[:suggestions],
commands: view_context.markdown(result[:commands])
}
}
end
+
+ def projects_filter_params
+ {
+ issuable_state_filter_enabled: true,
+ suggestions_filter_enabled: params[:preview_suggestions].present?
+ }
+ end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
index f48e0586211..ed9b898a2a3 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -26,4 +26,10 @@ module RendersCommits
commits
end
+
+ def valid_ref?(ref_name)
+ return true unless ref_name.present?
+
+ Gitlab::GitRefValidator.validate(ref_name)
+ end
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index e40a1a1d744..2510a31c9b3 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -11,6 +11,7 @@ class Projects::CommitsController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
before_action :authorize_download_code!
+ before_action :validate_ref!, except: :commits_root
before_action :set_commits, except: :commits_root
def commits_root
@@ -54,6 +55,10 @@ class Projects::CommitsController < Projects::ApplicationController
private
+ def validate_ref!
+ render_404 unless valid_ref?(@ref)
+ end
+
def set_commits
render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
@limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 2917925947f..5586c2fc631 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -65,12 +65,6 @@ class Projects::CompareController < Projects::ApplicationController
private
- def valid_ref?(ref_name)
- return true unless ref_name.present?
-
- Gitlab::GitRefValidator.validate(ref_name)
- end
-
def validate_refs!
valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) }
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index e940f382a19..a63eea0ca0e 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
+ before_action do
+ push_frontend_feature_flag(:area_chart, project)
+ end
+
def index
@environments = project.environments
.with_state(params[:scope] || :available)
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 4b6c5b215e8..8d8c62f1291 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -11,6 +11,10 @@ module DropdownsHelper
dropdown_output = dropdown_toggle(toggle_text, data_attr, options)
+ if options.key?(:toggle_link)
+ dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options)
+ end
+
dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
output = []
@@ -49,6 +53,11 @@ module DropdownsHelper
end
end
+ def dropdown_toggle_link(toggle_text, data_attr, options = {})
+ output = content_tag(:a, toggle_text, class: "dropdown-toggle-text #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), data: data_attr)
+ output.html_safe
+ end
+
def dropdown_title(title, options: {})
content_tag :div, class: "dropdown-title" do
title_output = []
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index dfa86f52e40..da991458ea7 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -179,7 +179,7 @@ module IssuablesHelper
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do
- author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true)
+ author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none")
if status = user_status(issuable.author)
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index a7fe8c3d59c..05da5ebdb22 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -47,8 +47,8 @@ module NavHelper
class_names
end
- def show_separator?
- Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics)
+ def has_extra_nav_icons?
+ Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) || current_user.admin?
end
def page_has_markdown?
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 7ce6b04df7e..87aebe415c8 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -50,6 +50,12 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts)
+ data_attrs = {
+ user_id: author.id,
+ username: author.username,
+ name: author.name
+ }
+
return "(deleted)" unless author
author_html = []
@@ -65,7 +71,7 @@ module ProjectsHelper
author_html = author_html.join.html_safe
if opts[:name]
- link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe
+ link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe
@@ -385,6 +391,10 @@ module ProjectsHelper
end
end
+ def sidebar_operations_link_path(project = @project)
+ metrics_project_environments_path(project) if can?(current_user, :read_environment, project)
+ end
+
def project_last_activity(project)
if project.last_activity_at
time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago')
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index cf60696ef39..2f802e4eab8 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -29,6 +29,11 @@ module SelectsHelper
classes = Array.wrap(opts[:class])
classes << 'ajax-groups-select'
+ # EE requires this line to be present, but there is no easy way of injecting
+ # this into EE without causing merge conflicts. Given this line is very
+ # simple and not really EE specific on its own, we just include it in CE.
+ classes << 'multiselect' if opts[:multiple]
+
opts[:class] = classes.join(' ')
select2_tag(id, opts)
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index f51b96ba8ce..67c808b167a 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -164,7 +164,7 @@ module SortingHelper
reverse_sort = issuable_reverse_sort_order_hash[sort_value]
if reverse_sort
- reverse_url = page_filter_path(sort: reverse_sort)
+ reverse_url = page_filter_path(sort: reverse_sort, label: true)
else
reverse_url = '#'
link_class += ' disabled'
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index eb315058c3a..f2cad09e779 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -26,6 +26,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_suggestion?
+ false
+ end
+
def discussions_rendered_on_frontend?
false
end
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index af699eeebce..498996f4f80 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -4,6 +4,8 @@ module Storage
module LegacyNamespace
extend ActiveSupport::Concern
+ include Gitlab::ShellAdapter
+
def move_dir
proj_with_tags = first_project_with_container_registry_tags
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index c32008aa9c7..279603496b0 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -66,10 +66,23 @@ class DiffNote < Note
self.original_position.diff_refs == diff_refs
end
+ def supports_suggestion?
+ return false unless noteable.supports_suggestion? && on_text?
+ # We don't want to trigger side-effects of `diff_file` call.
+ return false unless file = fetch_diff_file
+ return false unless line = file.line_for_position(self.original_position)
+
+ line&.suggestible?
+ end
+
def discussion_first_note?
self == discussion.first_note
end
+ def banzai_render_context(field)
+ super.merge(suggestions_filter_enabled: supports_suggestion?)
+ end
+
private
def enqueue_diff_file_creation_job
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 537f2a3a231..016c18ce6c8 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -3,8 +3,6 @@
class ProjectMember < Member
SOURCE_TYPE = 'Project'.freeze
- include Gitlab::ShellAdapter
-
belongs_to :project, foreign_key: 'source_id'
# Make sure project member points only to project as it source
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 77e48ce11e8..baf320d84a1 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -363,6 +363,11 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def supports_suggestion?
+ # Should be `true` when removing the FF.
+ Suggestion.feature_enabled?
+ end
+
# Calls `MergeWorker` to proceed with the merge process and
# updates `merge_jid` with the MergeWorker#jid.
# This helps tracking enqueued and ongoing merge jobs.
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 8865c164b11..3c9b1d32a53 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -3,7 +3,6 @@
class Namespace < ActiveRecord::Base
include CacheMarkdownField
include Sortable
- include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
include Routable
include AfterCommitQueue
diff --git a/app/models/note.rb b/app/models/note.rb
index a6ae4f58ac4..42237169722 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -69,6 +69,12 @@ class Note < ActiveRecord::Base
belongs_to :last_edited_by, class_name: 'User'
has_many :todos
+
+ # The delete_all definition is required here in order
+ # to generate the correct DELETE sql for
+ # suggestions.delete_all calls
+ has_many :suggestions, -> { order(:relative_order) },
+ inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
@@ -110,7 +116,7 @@ class Note < ActiveRecord::Base
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
- :system_note_metadata, :note_diff_file)
+ :system_note_metadata, :note_diff_file, :suggestions)
end
scope :with_notes_filter, -> (notes_filter) do
@@ -226,6 +232,10 @@ class Note < ActiveRecord::Base
Gitlab::HookData::NoteBuilder.new(self).build
end
+ def supports_suggestion?
+ false
+ end
+
def for_commit?
noteable_type == "Commit"
end
diff --git a/app/models/project.rb b/app/models/project.rb
index f5dc58cd67f..9e65f7bdbca 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -655,6 +655,11 @@ class Project < ActiveRecord::Base
end
end
+ def latest_successful_build_for(job_name, ref = default_branch)
+ builds = latest_successful_builds_for(ref)
+ builds.find_by!(name: job_name)
+ end
+
def merge_base_commit(first_commit_id, second_commit_id)
sha = repository.merge_base(first_commit_id, second_commit_id)
commit_by(oid: sha) if sha
@@ -2009,12 +2014,12 @@ class Project < ActiveRecord::Base
def create_new_pool_repository
pool = begin
- create_or_find_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self)
+ create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self)
rescue ActiveRecord::RecordNotUnique
- retry
+ pool_repository(true)
end
- pool.schedule
+ pool.schedule unless pool.scheduled?
pool
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 6c1073265a1..d075440b147 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProtectedBranch < ActiveRecord::Base
- include Gitlab::ShellAdapter
include ProtectedRef
protected_ref_access_levels :merge, :push
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 94746141945..d28ebabfe49 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProtectedTag < ActiveRecord::Base
- include Gitlab::ShellAdapter
include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
diff --git a/app/models/repository.rb b/app/models/repository.rb
index a9c167373c3..0ab7e711a01 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -17,7 +17,6 @@ class Repository
#{REF_ENVIRONMENTS}
].freeze
- include Gitlab::ShellAdapter
include Gitlab::RepositoryCacheAdapter
attr_accessor :full_path, :disk_path, :project, :is_wiki
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
new file mode 100644
index 00000000000..cec5ea30f9d
--- /dev/null
+++ b/app/models/suggestion.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class Suggestion < ApplicationRecord
+ FEATURE_FLAG = :diff_suggestions
+
+ belongs_to :note, inverse_of: :suggestions
+ validates :note, presence: true
+ validates :commit_id, presence: true, if: :applied?
+
+ delegate :original_position, :position, :diff_file,
+ :noteable, to: :note
+
+ def self.feature_enabled?
+ Feature.enabled?(FEATURE_FLAG)
+ end
+
+ def project
+ noteable.source_project
+ end
+
+ def branch
+ noteable.source_branch
+ end
+
+ # For now, suggestions only serve as a way to send patches that
+ # will change a single line (being able to apply multiple in the same place),
+ # which explains `from_line` and `to_line` being the same line.
+ # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
+ # when allowing multi-line suggestions.
+ def from_line
+ position.new_line
+ end
+ alias_method :to_line, :from_line
+
+ def from_original_line
+ original_position.new_line
+ end
+ alias_method :to_original_line, :from_original_line
+
+ # `from_line_index` and `to_line_index` represents diff/blob line numbers in
+ # index-like way (N-1).
+ def from_line_index
+ from_line - 1
+ end
+ alias_method :to_line_index, :from_line_index
+
+ def appliable?
+ return false unless note.supports_suggestion?
+
+ !applied? &&
+ noteable.opened? &&
+ different_content? &&
+ note.active?
+ end
+
+ private
+
+ def different_content?
+ from_content != to_content
+ end
+end
diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb
new file mode 100644
index 00000000000..301b7d965f5
--- /dev/null
+++ b/app/policies/suggestion_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class SuggestionPolicy < BasePolicy
+ delegate { @subject.project }
+
+ condition(:can_push_to_branch) do
+ Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch)
+ end
+
+ rule { can_push_to_branch }.enable :apply_suggestion
+end
diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb
index 942714b7787..bfef6d3bde8 100644
--- a/app/serializers/diff_line_entity.rb
+++ b/app/serializers/diff_line_entity.rb
@@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity
expose :rich_text do |line|
ERB::Util.html_escape(line.rich_text || line.text)
end
+
+ expose :suggestible?, as: :can_receive_suggestion
end
diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb
index 58ab804a3c8..e3dc43240c6 100644
--- a/app/serializers/issue_board_entity.rb
+++ b/app/serializers/issue_board_entity.rb
@@ -17,7 +17,7 @@ class IssueBoardEntity < Grape::Entity
end
expose :milestone, expose_nil: false do |issue|
- API::Entities::Project.represent issue.milestone, only: [:id, :title]
+ API::Entities::Milestone.represent issue.milestone, only: [:id, :title]
end
expose :assignees do |issue|
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index f33a1654d5e..9731b52f1ad 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity
end
end
+ expose :supports_suggestion?, as: :can_receive_suggestion
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index c6d27817411..1d3b59eb1b7 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note
end
end
+ expose :suggestions, using: SuggestionEntity
expose :resolved?, as: :resolved
expose :resolvable?, as: :resolvable
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
new file mode 100644
index 00000000000..4d0d4da10be
--- /dev/null
+++ b/app/serializers/suggestion_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class SuggestionEntity < API::Entities::Suggestion
+ include RequestAwareEntity
+
+ expose :current_user do
+ expose :can_apply do |suggestion|
+ Ability.allowed?(current_user, :apply_suggestion, suggestion)
+ end
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index e03789e3ca9..c4546f30235 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -36,6 +36,7 @@ module Notes
if !only_commands && note.save
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
+ Suggestions::CreateService.new(note).execute
end
if command_params.present?
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 35db409eb27..d2052bed646 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -14,6 +14,17 @@ module Notes
TodoService.new.update_note(note, current_user, old_mentioned_users)
end
+ if note.supports_suggestion?
+ Suggestion.transaction do
+ note.suggestions.delete_all
+ Suggestions::CreateService.new(note).execute
+ end
+
+ # We need to refresh the previous suggestions call cache
+ # in order to get the new records.
+ note.reload
+ end
+
note
end
end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index de8757006f1..a449a5dc3e9 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService
def execute
text, commands = explain_quick_actions(params[:text])
users = find_user_references(text)
+ suggestions = find_suggestions(text)
success(
text: text,
users: users,
+ suggestions: suggestions,
commands: commands.join(' '),
markdown_engine: markdown_engine
)
@@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService
extractor.users.map(&:username)
end
+ def find_suggestions(text)
+ return [] unless params[:preview_suggestions]
+
+ Banzai::SuggestionsParser.parse(text)
+ end
+
def find_commands_target
QuickActions::TargetService
.new(project, current_user)
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
new file mode 100644
index 00000000000..d931d528c86
--- /dev/null
+++ b/app/services/suggestions/apply_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class ApplyService < ::BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(suggestion)
+ unless suggestion.appliable?
+ return error('Suggestion is not appliable')
+ end
+
+ params = file_update_params(suggestion)
+ result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
+
+ if result[:status] == :success
+ suggestion.update(commit_id: result[:result], applied: true)
+ end
+
+ result
+ end
+
+ private
+
+ def file_update_params(suggestion)
+ diff_file = suggestion.diff_file
+
+ file_path = diff_file.file_path
+ branch_name = suggestion.noteable.source_branch
+ file_content = new_file_content(suggestion)
+ commit_message = "Apply suggestion to #{file_path}"
+
+ {
+ file_path: file_path,
+ branch_name: branch_name,
+ start_branch: branch_name,
+ commit_message: commit_message,
+ file_content: file_content
+ }
+ end
+
+ def new_file_content(suggestion)
+ range = suggestion.from_line_index..suggestion.to_line_index
+ blob = suggestion.diff_file.new_blob
+
+ blob.load_all_data!
+ content = blob.data.lines
+ content[range] = suggestion.to_content
+
+ content.join
+ end
+ end
+end
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
new file mode 100644
index 00000000000..77e958cbe0c
--- /dev/null
+++ b/app/services/suggestions/create_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class CreateService
+ def initialize(note)
+ @note = note
+ end
+
+ def execute
+ return unless @note.supports_suggestion?
+
+ suggestions = Banzai::SuggestionsParser.parse(@note.note)
+
+ # For single line suggestion we're only looking forward to
+ # change the line receiving the comment. Though, in
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
+ # we'll introduce a ```suggestion:L<x>-<y>, so this will
+ # slightly change.
+ comment_line = @note.position.new_line
+
+ rows =
+ suggestions.map.with_index do |suggestion, index|
+ from_content = changing_lines(comment_line, comment_line)
+
+ # The parsed suggestion doesn't have information about the correct
+ # ending characters (we may have a line break, or not), so we take
+ # this information from the last line being changed (last
+ # characters).
+ endline_chars = line_break_chars(from_content.lines.last)
+ to_content = "#{suggestion}#{endline_chars}"
+
+ {
+ note_id: @note.id,
+ from_content: from_content,
+ to_content: to_content,
+ relative_order: index
+ }
+ end
+
+ rows.in_groups_of(100, false) do |rows|
+ Gitlab::Database.bulk_insert('suggestions', rows)
+ end
+ end
+
+ private
+
+ def changing_lines(from_line, to_line)
+ @note.diff_file.new_blob_lines_between(from_line, to_line).join
+ end
+
+ def line_break_chars(line)
+ match = /\r\n|\r|\n/.match(line)
+ match[0] if match
+ end
+ end
+end
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
index 216acf79cbd..5feb0b0f05b 100644
--- a/app/validators/url_validator.rb
+++ b/app/validators/url_validator.rb
@@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator
ports: [],
allow_localhost: true,
allow_local_network: true,
+ ascii_only: false,
enforce_user: false
}
end
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index ac5916d129c..08a6359f777 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -36,6 +36,7 @@
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
+ = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab)
= Gon::Base.render_data
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index b7d69539eb7..e8d0d809181 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -15,7 +15,7 @@
= brand_header_logo
- logo_text = brand_header_logo_type
- if logo_text.present?
- %span.logo-text.d-none.d-sm-block
+ %span.logo-text.d-none.d-lg-block.prepend-left-8
= logo_text
- if current_user
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index ea5f2b166b4..7057a5a142f 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,3 +1,5 @@
+-# WAIT! Before adding more items to the nav bar, please see
+-# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information.
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
@@ -16,22 +18,22 @@
= render "layouts/nav/groups_dropdown/show"
- if dashboard_nav_link?(:activity)
- = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: _('Activity') do
= _('Activity')
- if dashboard_nav_link?(:milestones)
- = nav_link(controller: 'dashboard/milestones', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do
= _('Milestones')
- if dashboard_nav_link?(:snippets)
- = nav_link(controller: 'dashboard/snippets', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
= _('Snippets')
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
- %li.header-more.dropdown.d-lg-none.d-xl-none
+ %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) }
%a{ href: "#", data: { toggle: "dropdown" } }
= _('More')
= sprite_icon('angle-down', css_class: 'caret-down')
@@ -52,6 +54,21 @@
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
= _('Snippets')
+ = render_if_exists 'dashboard/operations/nav_link'
+ - if can?(current_user, :read_instance_statistics)
+ = nav_link(controller: [:conversational_development_index, :cohorts]) do
+ = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = _('Instance Statistics')
+ - if current_user.admin?
+ = nav_link(controller: 'admin/dashboard') do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = _('Admin Area')
+ - if Gitlab::Sherlock.enabled?
+ %li
+ = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'),
+ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = _('Sherlock Transactions')
+
-# Shortcut to Dashboard > Projects
- if dashboard_nav_link?(:projects)
%li.hidden
@@ -64,19 +81,17 @@
= link_to '#', class: 'dashboard-shortcuts-web-ide', title: _('Web IDE') do
= _('Web IDE')
- - if show_separator?
- %li.line-separator.d-none.d-sm-block
= render_if_exists 'dashboard/operations/nav_link'
- if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts]) do
+ = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do
= link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('chart', size: 18)
- if current_user.admin?
- = nav_link(controller: 'admin/dashboard') do
- = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin area'), aria: { label: _('Admin area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin', size: 18)
- if Gitlab::Sherlock.enabled?
%li
- = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'),
+ = link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index b89541a3c9f..bdd0108db0d 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -196,7 +196,7 @@
- if project_nav_tab? :operations
= nav_link(controller: sidebar_operations_paths) do
- = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do
+ = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do
.nav-icon-container
= sprite_icon('cloud-gear')
%span.nav-item-name
@@ -204,7 +204,7 @@
%ul.sidebar-sub-level-items
= nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to metrics_project_environments_path(@project) do
+ = link_to sidebar_operations_link_path do
%strong.fly-out-top-item-name
= _('Operations')
%li.divider.fly-out-top-item
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index cf273aab108..95c5eb32c7f 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -9,6 +9,6 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
- %article.file-holder
+ %article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index eb65cd90ea8..ff460a3831c 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,7 +1,7 @@
.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
- .file-content.wiki
+ .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(@blob.name, @content, legacy_render_context(params))
- else
.file-content.code.js-syntax-highlight
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index bd12cadf240..6edbfd91b21 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -2,5 +2,5 @@
- context = legacy_render_context(params)
- unless context[:markdown_engine] == :redcarpet
- context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
-.file-content.wiki
+.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 4ebb029e48b..a0a03838b10 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -67,6 +67,7 @@
noteable_data: serialize_issuable(@merge_request),
noteable_type: 'MergeRequest',
target_type: 'merge_request',
+ help_page_path: nil,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
#commits.commits.tab-pane
@@ -76,6 +77,7 @@
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
#js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ help_page_path: nil,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json,
project_path: project_path(@merge_request.project)} }
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index f495b4eaf30..da48cb207a4 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -6,7 +6,7 @@
= render 'shared/snippets/header'
.project-snippets
- %article.file-holder.snippet-file-content
+ %article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index cc38ec12fd8..4d5fd55364c 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
- .wiki
+ .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render_wiki_content(@page, legacy_render_context(params))
= render 'sidebar'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 5295e656ab0..9eecfa39390 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -16,7 +16,7 @@
- if current_user
.block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
- .block.assignee
+ .block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable
diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
index 3521f71f409..60c34094108 100644
--- a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
@@ -5,4 +5,4 @@
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
+ = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index d11476738e4..dd2cd36eac2 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -31,12 +31,12 @@
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('users')
- .profile-header
+ .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
- .user-info.prepend-left-default.append-right-default
+ .user-info
.cover-title
= @user.name
@@ -81,10 +81,10 @@
= icon('briefcase')
= @user.organization
- - if @user.bio.present?
- .cover-desc
- %p.profile-user-bio
- = @user.bio
+ - if @user.bio.present?
+ .cover-desc
+ %p.profile-user-bio
+ = @user.bio
- unless profile_tabs.empty?
.scrolling-tabs-container
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index 9d4e67deb9c..bd429d526bf 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -5,7 +5,6 @@ class RepositoryUpdateRemoteMirrorWorker
UpdateError = Class.new(StandardError)
include ApplicationWorker
- include Gitlab::ShellAdapter
sidekiq_options retry: 3, dead: false
diff --git a/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml b/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml
new file mode 100644
index 00000000000..0b5d1a6b05a
--- /dev/null
+++ b/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve Main navbar is broken in certain viewport widths
+merge_request: 23348
+author:
+type: fixed
diff --git a/changelogs/unreleased/50157-extended-user-centric-tooltips.yml b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml
new file mode 100644
index 00000000000..3b55a867b87
--- /dev/null
+++ b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml
@@ -0,0 +1,5 @@
+---
+title: Extended user centric tooltips on issue and MR page
+merge_request: 23231
+author:
+type: added
diff --git a/changelogs/unreleased/51122-fix-navigating-discussions.yml b/changelogs/unreleased/51122-fix-navigating-discussions.yml
new file mode 100644
index 00000000000..94d76654589
--- /dev/null
+++ b/changelogs/unreleased/51122-fix-navigating-discussions.yml
@@ -0,0 +1,5 @@
+---
+title: Fix navigating by unresolved discussions on Merge Request page
+merge_request: 22789
+author:
+type: fixed
diff --git a/changelogs/unreleased/52774-fix-svgs-in-ie-11.yml b/changelogs/unreleased/52774-fix-svgs-in-ie-11.yml
new file mode 100644
index 00000000000..656a915a281
--- /dev/null
+++ b/changelogs/unreleased/52774-fix-svgs-in-ie-11.yml
@@ -0,0 +1,5 @@
+---
+title: Ensure that SVG sprite icons are properly rendered in IE11
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml b/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml
new file mode 100644
index 00000000000..fa905b47ca2
--- /dev/null
+++ b/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml
@@ -0,0 +1,5 @@
+---
+title: Add new endpoint to download single artifact file for a ref
+merge_request: 23538
+author:
+type: added
diff --git a/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml b/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml
new file mode 100644
index 00000000000..d2ff095ce55
--- /dev/null
+++ b/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml
@@ -0,0 +1,5 @@
+---
+title: Fix due date test
+merge_request: 23845
+author:
+type: other
diff --git a/changelogs/unreleased/commit-badge-style-fix.yml b/changelogs/unreleased/commit-badge-style-fix.yml
new file mode 100644
index 00000000000..d7b37717853
--- /dev/null
+++ b/changelogs/unreleased/commit-badge-style-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed styling of image comment badges on commits
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/define-default-value-for-only-except-keys.yml b/changelogs/unreleased/define-default-value-for-only-except-keys.yml
index 3e5ecdcf51e..ed0e982f0fc 100644
--- a/changelogs/unreleased/define-default-value-for-only-except-keys.yml
+++ b/changelogs/unreleased/define-default-value-for-only-except-keys.yml
@@ -1,5 +1,5 @@
---
title: Define the default value for only/except policies
-merge_request: 23531
+merge_request: 23765
author:
type: changed
diff --git a/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml b/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml
new file mode 100644
index 00000000000..09a10a86adc
--- /dev/null
+++ b/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml
@@ -0,0 +1,5 @@
+---
+title: Populate MR metrics with events table information (migration)
+merge_request: 23564
+author:
+type: performance
diff --git a/changelogs/unreleased/profile-fixing.yml b/changelogs/unreleased/profile-fixing.yml
new file mode 100644
index 00000000000..7e255d997d8
--- /dev/null
+++ b/changelogs/unreleased/profile-fixing.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bottom paddings of profile header and some markup updates of profile
+merge_request: 23168
+author: Harry Kiselev
+type: other
diff --git a/changelogs/unreleased/suggest-change-to-diff-line.yml b/changelogs/unreleased/suggest-change-to-diff-line.yml
new file mode 100644
index 00000000000..cb949f14e8c
--- /dev/null
+++ b/changelogs/unreleased/suggest-change-to-diff-line.yml
@@ -0,0 +1,5 @@
+---
+title: Add ability to render suggestions
+merge_request: 23147
+author:
+type: added
diff --git a/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml b/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml
new file mode 100644
index 00000000000..1389693b9a9
--- /dev/null
+++ b/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade workhorse to 7.6.0
+merge_request: 23694
+author:
+type: other
diff --git a/changelogs/unreleased/winh-dropdown-item-padding.yml b/changelogs/unreleased/winh-dropdown-item-padding.yml
new file mode 100644
index 00000000000..9f18abba9d1
--- /dev/null
+++ b/changelogs/unreleased/winh-dropdown-item-padding.yml
@@ -0,0 +1,5 @@
+---
+title: Adjust dropdown item and header padding to comply with design specs
+merge_request: 23552
+author:
+type: changed
diff --git a/changelogs/unreleased/winh-resolved-discussions-reply-field.yml b/changelogs/unreleased/winh-resolved-discussions-reply-field.yml
new file mode 100644
index 00000000000..01cf35ae8a7
--- /dev/null
+++ b/changelogs/unreleased/winh-resolved-discussions-reply-field.yml
@@ -0,0 +1,5 @@
+---
+title: Display reply field if resolved discussion has no replies
+merge_request: 23801
+author:
+type: fixed
diff --git a/config/application.rb b/config/application.rb
index 63a5b483fc2..f10b8ed5bd2 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -154,6 +154,7 @@ module Gitlab
config.assets.precompile << "locale/**/app.js"
config.assets.precompile << "emoji_sprites.css"
config.assets.precompile << "errors.css"
+ config.assets.precompile << "csslab.css"
# Import gitlab-svgs directly from vendored directory
config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist"
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index 6e4f7ce30a0..af76bace577 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -592,9 +592,10 @@
in compiled/distributed product so attribution not needed.
:versions: []
:when: 2018-10-02 19:23:54.840151000 Z
-- - :approve
+- - :license
- echarts
- - :who: Mike Greiling
+ - Apache 2.0
+ - :who: Adriel Santiago
:why: https://github.com/apache/incubator-echarts/blob/master/LICENSE
:versions: []
- :when: 2018-12-05 22:12:30.550027000 Z
+ :when: 2018-12-07 20:46:12.421256000 Z
diff --git a/db/migrate/20181123144235_create_suggestions.rb b/db/migrate/20181123144235_create_suggestions.rb
new file mode 100644
index 00000000000..bcc4d8c538b
--- /dev/null
+++ b/db/migrate/20181123144235_create_suggestions.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class CreateSuggestions < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :suggestions, id: :bigserial do |t|
+ t.references :note, foreign_key: { on_delete: :cascade }, null: false
+ t.integer :relative_order, null: false, limit: 2
+ t.boolean :applied, null: false, default: false
+ t.string :commit_id
+ t.text :from_content, null: false
+ t.text :to_content, null: false
+
+ t.index [:note_id, :relative_order],
+ name: 'index_suggestions_on_note_id_and_relative_order',
+ unique: true
+ end
+ end
+end
diff --git a/db/post_migrate/20161221153951_rename_reserved_project_names.rb b/db/post_migrate/20161221153951_rename_reserved_project_names.rb
index b7665e98490..50e1c8449ba 100644
--- a/db/post_migrate/20161221153951_rename_reserved_project_names.rb
+++ b/db/post_migrate/20161221153951_rename_reserved_project_names.rb
@@ -1,6 +1,5 @@
class RenameReservedProjectNames < ActiveRecord::Migration[4.2]
include Gitlab::Database::MigrationHelpers
- include Gitlab::ShellAdapter
DOWNTIME = false
diff --git a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
index cac3fd713eb..bef669b459d 100644
--- a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
+++ b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
@@ -1,6 +1,5 @@
class RenameMoreReservedProjectNames < ActiveRecord::Migration[4.2]
include Gitlab::Database::MigrationHelpers
- include Gitlab::ShellAdapter
DOWNTIME = false
diff --git a/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb b/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb
new file mode 100644
index 00000000000..1e43e3dd790
--- /dev/null
+++ b/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb
@@ -0,0 +1,38 @@
+# 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 PopulateMrMetricsWithEventsData < ActiveRecord::Migration[4.2]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BATCH_SIZE = 10_000
+ MIGRATION = 'PopulateMergeRequestMetricsWithEventsDataImproved'
+ PREVIOUS_MIGRATION = 'PopulateMergeRequestMetricsWithEventsData'
+
+ disable_ddl_transaction!
+
+ def up
+ # Perform any ongoing background migration that might still be running from
+ # previous try (see https://gitlab.com/gitlab-org/gitlab-ce/issues/47676).
+ Gitlab::BackgroundMigration.steal(PREVIOUS_MIGRATION)
+
+ say 'Scheduling `PopulateMergeRequestMetricsWithEventsData` jobs'
+ # It will update around 4_000_000 records in batches of 10_000 merge
+ # requests (running between 5 minutes) and should take around 53 hours to complete.
+ # Apparently, production PostgreSQL is able to vacuum 10k-20k dead_tuples
+ # per minute. So this should give us enough space.
+ #
+ # More information about the updates in `PopulateMergeRequestMetricsWithEventsDataImproved` class.
+ #
+ MergeRequest.all.each_batch(of: BATCH_SIZE) do |relation, index|
+ range = relation.pluck('MIN(id)', 'MAX(id)').first
+
+ BackgroundMigrationWorker.perform_in(index * 8.minutes, MIGRATION, range)
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ff2dde3243c..10b7aa8a99f 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: 20181203002526) do
+ActiveRecord::Schema.define(version: 20181204154019) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -1956,6 +1956,16 @@ ActiveRecord::Schema.define(version: 20181203002526) do
t.index ["subscribable_id", "subscribable_type", "user_id", "project_id"], name: "index_subscriptions_on_subscribable_and_user_id_and_project_id", unique: true, using: :btree
end
+ create_table "suggestions", id: :bigserial, force: :cascade do |t|
+ t.integer "note_id", null: false
+ t.integer "relative_order", limit: 2, null: false
+ t.boolean "applied", default: false, null: false
+ t.string "commit_id"
+ t.text "from_content", null: false
+ t.text "to_content", null: false
+ t.index ["note_id", "relative_order"], name: "index_suggestions_on_note_id_and_relative_order", unique: true, using: :btree
+ end
+
create_table "system_note_metadata", force: :cascade do |t|
t.integer "note_id", null: false
t.integer "commit_count"
@@ -2432,6 +2442,7 @@ ActiveRecord::Schema.define(version: 20181203002526) do
add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
add_foreign_key "subscriptions", "projects", on_delete: :cascade
+ add_foreign_key "suggestions", "notes", on_delete: :cascade
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
add_foreign_key "term_agreements", "application_setting_terms", column: "term_id"
add_foreign_key "term_agreements", "users", on_delete: :cascade
diff --git a/doc/api/jobs.md b/doc/api/jobs.md
index aa290ff4cf8..589c48ee08d 100644
--- a/doc/api/jobs.md
+++ b/doc/api/jobs.md
@@ -404,7 +404,7 @@ Example response:
[ce-5347]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5347
-## Download a single artifact file
+## Download a single artifact file by job ID
> Introduced in GitLab 10.0
@@ -438,6 +438,41 @@ Example response:
| 400 | Invalid path provided |
| 404 | Build not found or no file/artifacts |
+## Download a single artifact file from specific tag or branch
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23538) in GitLab 11.5.
+
+Download a single artifact file from a specific tag or branch from within the
+job's artifacts archive. The file is extracted from the archive and streamed to
+the client.
+
+```
+GET /projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+|-----------------|----------------|----------|------------------------------------------------------------------------------------------------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. |
+| `ref_name` | string | yes | Branch or tag name in repository. HEAD or SHA references are not supported. |
+| `artifact_path` | string | yes | Path to a file inside the artifacts archive. |
+| `job` | string | yes | The name of the job. |
+
+Example request:
+
+```sh
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/master/raw/some/release/file.pdf?job=pdf"
+```
+
+Possible response status codes:
+
+| Status | Description |
+|-----------|--------------------------------------|
+| 200 | Sends a single artifact file |
+| 400 | Invalid path provided |
+| 404 | Build not found or no file/artifacts |
+
## Get a trace file
Get a trace of a specific job of a project
diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md
index 2c799e83a5f..d8136b80c11 100644
--- a/doc/ci/interactive_web_terminal/index.md
+++ b/doc/ci/interactive_web_terminal/index.md
@@ -1,4 +1,4 @@
-# Getting started with interactive web terminals **[CORE ONLY]**
+# Interactive Web Terminals **[CORE ONLY]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/50144) in GitLab 11.3.
@@ -15,7 +15,7 @@ progress.
Two things need to be configured for the interactive web terminal to work:
- The Runner needs to have [`[session_server]` configured
- properly][session-server]
+ properly](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section)
- If you are using a reverse proxy with your GitLab instance, web terminals need to be
[enabled](../../administration/integration/terminal.md#enabling-and-disabling-terminal-support)
@@ -45,9 +45,7 @@ the terminal and type commands like a normal shell.
If you have the terminal open and the job has finished with its tasks, the
terminal will block the job from finishing for the duration configured in
-[`[session_server].terminal_max_retention_time`][session-server] until you
+[`[session_server].terminal_max_retention_time`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section) until you
close the terminal window.
![finished job with terminal open](img/finished_job_with_terminal_open.png)
-
-[session-server]: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section
diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md
index e6b1f6b6aae..55e53b865af 100644
--- a/doc/user/project/web_ide/index.md
+++ b/doc/user/project/web_ide/index.md
@@ -1,6 +1,6 @@
# Web IDE
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ee/issues/4539) [GitLab Ultimate][ee] 10.4.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/4539) in [GitLab Ultimate][ee] 10.4.
> [Brought to GitLab Core](https://gitlab.com/gitlab-org/gitlab-ce/issues/44157) in 10.7.
The Web IDE makes it faster and easier to contribute changes to your projects
@@ -15,7 +15,7 @@ and from merge requests.
## File finder
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18323) [GitLab Core][ce] 10.8.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18323) in [GitLab Core][ce] 10.8.
The file finder allows you to quickly open files in the current branch by
searching. The file finder is launched using the keyboard shortcut `Command-p`,
@@ -65,7 +65,7 @@ shows you a preview of the merge request diff if you commit your changes.
## View CI job logs
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19279) [GitLab Core][ce] 11.0.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19279) in [GitLab Core][ce] 11.0.
The Web IDE can be used to quickly fix failing tests by opening the branch or
merge request in the Web IDE and opening the logs of the failed job. The status
@@ -77,7 +77,7 @@ left.
## Switching merge requests
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) in [GitLab Core][ce] 11.0.
Switching between your authored and assigned merge requests can be done without
leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
@@ -86,7 +86,7 @@ switching to a different merge request.
## Switching branches
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) [GitLab Core][ce] 11.2.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) in [GitLab Core][ce] 11.2.
Switching between branches of the current project repository can be done without
leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
@@ -95,7 +95,7 @@ switching to a different branch.
## Client Side Evaluation
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19764) [GitLab Core][ce] 11.2.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19764) in [GitLab Core][ce] 11.2.
The Web IDE can be used to preview JavaScript projects right in the browser.
This feature uses CodeSandbox to compile and bundle the JavaScript used to
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 8abb24e6f69..f1448da7403 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -149,6 +149,7 @@ module API
mount ::API::Snippets
mount ::API::Submodules
mount ::API::Subscriptions
+ mount ::API::Suggestions
mount ::API::SystemHooks
mount ::API::Tags
mount ::API::Templates
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 5dbfbb85e9e..b83a5c14190 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1495,5 +1495,17 @@ module API
expose :label, using: Entities::LabelBasic
expose :action
end
+
+ class Suggestion < Grape::Entity
+ expose :id
+ expose :from_original_line
+ expose :to_original_line
+ expose :from_line
+ expose :to_line
+ expose :appliable?, as: :appliable
+ expose :applied
+ expose :from_content
+ expose :to_content
+ end
end
end
diff --git a/lib/api/job_artifacts.rb b/lib/api/job_artifacts.rb
index 7c2d8ff11bf..a4068a200b3 100644
--- a/lib/api/job_artifacts.rb
+++ b/lib/api/job_artifacts.rb
@@ -35,6 +35,29 @@ module API
end
# rubocop: enable CodeReuse/ActiveRecord
+ desc 'Download a specific file from artifacts archive from a ref' do
+ detail 'This feature was introduced in GitLab 11.5'
+ end
+ params do
+ requires :ref_name, type: String, desc: 'The ref from repository'
+ requires :job, type: String, desc: 'The name for the job'
+ requires :artifact_path, type: String, desc: 'Artifact path'
+ end
+ get ':id/jobs/artifacts/:ref_name/raw/*artifact_path',
+ format: false,
+ requirements: { ref_name: /.+/ } do
+ authorize_download_artifacts!
+
+ build = user_project.latest_successful_build_for(params[:job], params[:ref_name])
+
+ path = Gitlab::Ci::Build::Artifacts::Path
+ .new(params[:artifact_path])
+
+ bad_request! unless path.valid?
+
+ send_artifacts_entry(build, path)
+ end
+
desc 'Download the artifacts archive from a job' do
detail 'This feature was introduced in GitLab 8.5'
end
@@ -65,6 +88,7 @@ module API
path = Gitlab::Ci::Build::Artifacts::Path
.new(params[:artifact_path])
+
bad_request! unless path.valid?
send_artifacts_entry(build, path)
diff --git a/lib/api/suggestions.rb b/lib/api/suggestions.rb
new file mode 100644
index 00000000000..d008d1b9e97
--- /dev/null
+++ b/lib/api/suggestions.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module API
+ class Suggestions < Grape::API
+ before { authenticate! }
+
+ resource :suggestions do
+ desc 'Apply suggestion patch in the Merge Request it was created' do
+ success Entities::Suggestion
+ end
+ params do
+ requires :id, type: String, desc: 'The suggestion ID'
+ end
+ put ':id/apply' do
+ suggestion = Suggestion.find_by_id(params[:id])
+
+ not_found! unless suggestion
+ authorize! :apply_suggestion, suggestion
+
+ result = ::Suggestions::ApplyService.new(current_user).execute(suggestion)
+
+ if result[:status] == :success
+ present suggestion, with: Entities::Suggestion, current_user: current_user
+ else
+ http_status = result[:http_status] || 400
+ render_api_error!(result[:message], http_status)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/suggestion_filter.rb b/lib/banzai/filter/suggestion_filter.rb
new file mode 100644
index 00000000000..822db7cf26e
--- /dev/null
+++ b/lib/banzai/filter/suggestion_filter.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Banzai
+ module Filter
+ class SuggestionFilter < HTML::Pipeline::Filter
+ # Class used for tagging elements that should be rendered
+ TAG_CLASS = 'js-render-suggestion'.freeze
+
+ def call
+ return doc unless Suggestion.feature_enabled?
+ return doc unless suggestions_filter_enabled?
+
+ doc.search('pre.suggestion > code').each do |node|
+ node.add_class(TAG_CLASS)
+ end
+
+ doc
+ end
+
+ def suggestions_filter_enabled?
+ context[:suggestions_filter_enabled]
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index 8a7f9045c24..18e5e9185de 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -69,7 +69,7 @@ module Banzai
end
def use_rouge?(language)
- %w(math mermaid plantuml).exclude?(language)
+ %w(math mermaid plantuml suggestion).exclude?(language)
end
end
end
diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb
index 11960047e5b..8cda67867a8 100644
--- a/lib/banzai/filter/user_reference_filter.rb
+++ b/lib/banzai/filter/user_reference_filter.rb
@@ -106,7 +106,7 @@ module Banzai
end
def link_class
- reference_class(:project_member)
+ reference_class(:project_member, tooltip: false)
end
def link_to_all(link_content: nil)
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index 96bea7ca935..5f13a6d6cde 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -29,6 +29,7 @@ module Banzai
Filter::TableOfContentsFilter,
Filter::AutolinkFilter,
Filter::ExternalLinkFilter,
+ Filter::SuggestionFilter,
*reference_filters,
diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb
index 63a998a2c1f..7eaad6d7560 100644
--- a/lib/banzai/pipeline/post_process_pipeline.rb
+++ b/lib/banzai/pipeline/post_process_pipeline.rb
@@ -14,7 +14,8 @@ module Banzai
[
Filter::RedactorFilter,
Filter::RelativeLinkFilter,
- Filter::IssuableStateFilter
+ Filter::IssuableStateFilter,
+ Filter::SuggestionFilter
]
end
diff --git a/lib/banzai/suggestions_parser.rb b/lib/banzai/suggestions_parser.rb
new file mode 100644
index 00000000000..09f36635020
--- /dev/null
+++ b/lib/banzai/suggestions_parser.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Banzai
+ module SuggestionsParser
+ # Returns the content of each suggestion code block.
+ #
+ def self.parse(text)
+ html = Banzai.render(text, project: nil, no_original_data: true)
+ doc = Nokogiri::HTML(html)
+
+ doc.search('pre.suggestion').map { |node| node.text }
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb
new file mode 100644
index 00000000000..37592d67dd9
--- /dev/null
+++ b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class PopulateMergeRequestMetricsWithEventsDataImproved
+ CLOSED_EVENT_ACTION = 3
+ MERGED_EVENT_ACTION = 7
+
+ def perform(min_merge_request_id, max_merge_request_id)
+ insert_metrics_for_range(min_merge_request_id, max_merge_request_id)
+ update_metrics_with_events_data(min_merge_request_id, max_merge_request_id)
+ end
+
+ # Inserts merge_request_metrics records for merge_requests without it for
+ # a given merge request batch.
+ def insert_metrics_for_range(min, max)
+ metrics_not_exists_clause =
+ <<-SQL.strip_heredoc
+ NOT EXISTS (SELECT 1 FROM merge_request_metrics
+ WHERE merge_request_metrics.merge_request_id = merge_requests.id)
+ SQL
+
+ MergeRequest.where(metrics_not_exists_clause).where(id: min..max).each_batch do |batch|
+ select_sql = batch.select(:id, :created_at, :updated_at).to_sql
+
+ execute("INSERT INTO merge_request_metrics (merge_request_id, created_at, updated_at) #{select_sql}")
+ end
+ end
+
+ def update_metrics_with_events_data(min, max)
+ if Gitlab::Database.postgresql?
+ psql_update_metrics_with_events_data(min, max)
+ else
+ mysql_update_metrics_with_events_data(min, max)
+ end
+ end
+
+ def psql_update_metrics_with_events_data(min, max)
+ update_sql = <<-SQL.strip_heredoc
+ UPDATE merge_request_metrics
+ SET (latest_closed_at,
+ latest_closed_by_id) =
+ ( SELECT updated_at,
+ author_id
+ FROM events
+ WHERE target_id = merge_request_id
+ AND target_type = 'MergeRequest'
+ AND action = #{CLOSED_EVENT_ACTION}
+ ORDER BY id DESC
+ LIMIT 1 ),
+ merged_by_id =
+ ( SELECT author_id
+ FROM events
+ WHERE target_id = merge_request_id
+ AND target_type = 'MergeRequest'
+ AND action = #{MERGED_EVENT_ACTION}
+ ORDER BY id DESC
+ LIMIT 1 )
+ WHERE merge_request_id BETWEEN #{min} AND #{max}
+ SQL
+
+ execute(update_sql)
+ end
+
+ def mysql_update_metrics_with_events_data(min, max)
+ closed_updated_at_subquery = mysql_events_select(:updated_at, CLOSED_EVENT_ACTION)
+ closed_author_id_subquery = mysql_events_select(:author_id, CLOSED_EVENT_ACTION)
+ merged_author_id_subquery = mysql_events_select(:author_id, MERGED_EVENT_ACTION)
+
+ update_sql = <<-SQL.strip_heredoc
+ UPDATE merge_request_metrics
+ SET latest_closed_at = (#{closed_updated_at_subquery}),
+ latest_closed_by_id = (#{closed_author_id_subquery}),
+ merged_by_id = (#{merged_author_id_subquery})
+ WHERE merge_request_id BETWEEN #{min} AND #{max}
+ SQL
+
+ execute(update_sql)
+ end
+
+ def mysql_events_select(column, action)
+ <<-SQL.strip_heredoc
+ SELECT #{column} FROM events
+ WHERE target_id = merge_request_id
+ AND target_type = 'MergeRequest'
+ AND action = #{action}
+ ORDER BY id DESC
+ LIMIT 1
+ SQL
+ end
+
+ def execute(sql)
+ @connection ||= ActiveRecord::Base.connection
+ @connection.execute(sql)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb
index d4080536d81..28cfb46e2d4 100644
--- a/lib/gitlab/bitbucket_server_import/importer.rb
+++ b/lib/gitlab/bitbucket_server_import/importer.rb
@@ -3,8 +3,6 @@
module Gitlab
module BitbucketServerImport
class Importer
- include Gitlab::ShellAdapter
-
attr_reader :recover_missing_commits
attr_reader :project, :project_key, :repository_slug, :client, :errors, :users
attr_accessor :logger
diff --git a/lib/gitlab/ci/build/policy/refs.rb b/lib/gitlab/ci/build/policy/refs.rb
index 10934536536..0e9bb5c94bb 100644
--- a/lib/gitlab/ci/build/policy/refs.rb
+++ b/lib/gitlab/ci/build/policy/refs.rb
@@ -32,10 +32,14 @@ module Gitlab
return true if pipeline.source == pattern
return true if pipeline.source&.pluralize == pattern
- if pattern.first == "/" && pattern.last == "/"
- Regexp.new(pattern[1...-1]) =~ pipeline.ref
- else
- pattern == pipeline.ref
+ # patterns can be matched only when branch or tag is used
+ # the pattern matching does not work for merge requests pipelines
+ if pipeline.branch? || pipeline.tag?
+ if pattern.first == "/" && pattern.last == "/"
+ Regexp.new(pattern[1...-1]) =~ pipeline.ref
+ else
+ pattern == pipeline.ref
+ end
end
end
end
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index 085be5da08d..22400798e9e 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -16,6 +16,13 @@ module Gitlab
dependencies before_script after_script variables
environment coverage retry parallel extends].freeze
+ DEFAULT_ONLY_POLICY = {
+ refs: %w(branches tags)
+ }.freeze
+
+ DEFAULT_EXCEPT_POLICY = {
+ }.freeze
+
validations do
validates :config, allowed_keys: ALLOWED_KEYS
validates :config, presence: true
@@ -154,8 +161,8 @@ module Gitlab
services: services_value,
stage: stage_value,
cache: cache_value,
- only: only_value,
- except: except_value,
+ only: DEFAULT_ONLY_POLICY.deep_merge(only_value.to_h),
+ except: DEFAULT_EXCEPT_POLICY.deep_merge(except_value.to_h),
variables: variables_defined? ? variables_value : nil,
environment: environment_defined? ? environment_value : nil,
environment_name: environment_defined? ? environment_value[:name] : nil,
diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb
index 81e74a639fc..d98b60f07d6 100644
--- a/lib/gitlab/ci/config/entry/policy.rb
+++ b/lib/gitlab/ci/config/entry/policy.rb
@@ -8,6 +8,9 @@ module Gitlab
# Base class for OnlyPolicy and ExceptPolicy
#
class Policy < ::Gitlab::Config::Entry::Simplifiable
+ strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) }
+ strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) }
+
class RefsPolicy < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index 8ba44dff06f..a85c5386d98 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -122,6 +122,16 @@ module Gitlab
old_blob_lazy&.itself
end
+ def new_blob_lines_between(from_line, to_line)
+ return [] unless new_blob
+
+ from_index = from_line - 1
+ to_index = to_line - 1
+
+ new_blob.load_all_data!
+ new_blob.data.lines[from_index..to_index]
+ end
+
def content_sha
new_content_sha || old_content_sha
end
diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb
index f0c4977fc50..001748afb41 100644
--- a/lib/gitlab/diff/line.rb
+++ b/lib/gitlab/diff/line.rb
@@ -73,6 +73,10 @@ module Gitlab
!meta?
end
+ def suggestible?
+ !removed?
+ end
+
def rich_text
@parent_file.try(:highlight_lines!) if @parent_file && !@rich_text
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index 255601382b1..11021ee06b3 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -193,7 +193,7 @@ module Gitlab
feature = feature_stack && feature_stack[0]
metadata['call_site'] = feature.to_s if feature
metadata['gitaly-servers'] = address_metadata(remote_storage) if remote_storage
- metadata['correlation_id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id
+ metadata['x-gitlab-correlation-id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id
metadata.merge!(server_feature_flags)
diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb
index 921a06b4023..91167a9c4fb 100644
--- a/lib/gitlab/import_export/repo_restorer.rb
+++ b/lib/gitlab/import_export/repo_restorer.rb
@@ -4,7 +4,6 @@ module Gitlab
module ImportExport
class RepoRestorer
include Gitlab::ImportExport::CommandLineUtil
- include Gitlab::ShellAdapter
def initialize(project:, shared:, path_to_bundle:)
@project = project
diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb
index b8040f73cee..44c71f8431d 100644
--- a/lib/gitlab/url_blocker.rb
+++ b/lib/gitlab/url_blocker.rb
@@ -8,7 +8,7 @@ module Gitlab
BlockedUrlError = Class.new(StandardError)
class << self
- def validate!(url, allow_localhost: false, allow_local_network: true, enforce_user: false, ports: [], protocols: [])
+ def validate!(url, ports: [], protocols: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false)
return true if url.nil?
# Param url can be a string, URI or Addressable::URI
@@ -22,6 +22,7 @@ module Gitlab
validate_port!(port, ports) if ports.any?
validate_user!(uri.user) if enforce_user
validate_hostname!(uri.hostname)
+ validate_unicode_restriction!(uri) if ascii_only
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr|
@@ -91,6 +92,12 @@ module Gitlab
raise BlockedUrlError, "Hostname or IP address invalid"
end
+ def validate_unicode_restriction!(uri)
+ return if uri.to_s.ascii_only?
+
+ raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}"
+ end
+
def validate_localhost!(addrs_info)
local_ips = ["::", "0.0.0.0"]
local_ips.concat(Socket.ip_address_list.map(&:ip_address))
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 008e9cd1d24..083c620267a 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -85,6 +85,8 @@ module Gitlab
releases: count(Release),
remote_mirrors: count(RemoteMirror),
snippets: count(Snippet),
+ suggestions: count(Suggestion),
+ todos: count(Todo),
uploads: count(Upload),
web_hooks: count(WebHook)
}.merge(services_usage).merge(approximate_counts)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4f55eac44ab..48a8bb391f5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -97,6 +97,9 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr ""
+msgid "%{bio} at %{organization}"
+msgstr ""
+
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
@@ -402,9 +405,6 @@ msgstr ""
msgid "Admin Overview"
msgstr ""
-msgid "Admin area"
-msgstr ""
-
msgid "AdminArea| You are about to permanently delete the user %{username}. Issues, merge requests, and groups linked to them will be transferred to a system-wide \"Ghost-user\". To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered."
msgstr ""
@@ -657,6 +657,12 @@ msgstr ""
msgid "Applications"
msgstr ""
+msgid "Applied"
+msgstr ""
+
+msgid "Apply suggestion"
+msgstr ""
+
msgid "Apr"
msgstr ""
@@ -846,6 +852,9 @@ msgstr ""
msgid "Available specific runners"
msgstr ""
+msgid "Avatar for %{assigneeName}"
+msgstr ""
+
msgid "Avatar will be removed. Are you sure?"
msgstr ""
@@ -2917,6 +2926,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
+msgid "Expired %{expiredOn}"
+msgstr ""
+
msgid "Expires in %{expires_at}"
msgstr ""
@@ -3588,6 +3600,9 @@ msgstr ""
msgid "Input your repository URL"
msgstr ""
+msgid "Insert suggestion"
+msgstr ""
+
msgid "Install GitLab Runner"
msgstr ""
@@ -6068,6 +6083,9 @@ msgstr ""
msgid "Something went wrong when toggling the button"
msgstr ""
+msgid "Something went wrong while applying the suggestion. Please try again."
+msgstr ""
+
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr ""
@@ -6275,6 +6293,12 @@ msgstr ""
msgid "Started"
msgstr ""
+msgid "Started %{startsIn}"
+msgstr ""
+
+msgid "Starts %{startsIn}"
+msgstr ""
+
msgid "Starts at (UTC)"
msgstr ""
@@ -6326,6 +6350,9 @@ msgstr ""
msgid "Subscribed"
msgstr ""
+msgid "Suggested change"
+msgstr ""
+
msgid "Switch branch/tag"
msgstr ""
diff --git a/package.json b/package.json
index ac4d5174610..d546a32cba5 100644
--- a/package.json
+++ b/package.json
@@ -25,8 +25,9 @@
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-syntax-import-meta": "^7.0.0",
"@babel/preset-env": "^7.1.0",
- "@gitlab/svgs": "^1.40.0",
- "@gitlab/ui": "^1.14.0",
+ "@gitlab/csslab": "^1.8.0",
+ "@gitlab/svgs": "^1.42.0",
+ "@gitlab/ui": "^1.15.0",
"apollo-boost": "^0.1.20",
"apollo-client": "^2.4.5",
"autosize": "^4.0.0",
@@ -57,6 +58,7 @@
"diff": "^3.4.0",
"document-register-element": "1.3.0",
"dropzone": "^4.2.0",
+ "echarts": "^4.2.0-rc.2",
"emoji-unicode-version": "^0.2.1",
"exports-loader": "^0.7.0",
"file-loader": "^2.0.0",
diff --git a/qa/qa/page/merge_request/new.rb b/qa/qa/page/merge_request/new.rb
index 1f8f1fbca8e..20d9c336367 100644
--- a/qa/qa/page/merge_request/new.rb
+++ b/qa/qa/page/merge_request/new.rb
@@ -26,6 +26,10 @@ module QA
element :issuable_label
end
+ view 'app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml' do
+ element :assign_to_me_link
+ end
+
def create_merge_request
click_element :issuable_create_button
end
@@ -50,6 +54,10 @@ module QA
click_link label.title
end
+
+ def assign_to_me
+ click_element :assign_to_me_link
+ end
end
end
end
diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb
index 2fd30e15ffb..869dc0b9d21 100644
--- a/qa/qa/page/merge_request/show.rb
+++ b/qa/qa/page/merge_request/show.rb
@@ -52,6 +52,7 @@ module QA
end
view 'app/views/shared/issuable/_sidebar.html.haml' do
+ element :assignee_block
element :labels_block
end
@@ -100,6 +101,12 @@ module QA
end
end
+ def has_assignee?(username)
+ page.within(element_selector_css(:assignee_block)) do
+ has_text?(username)
+ end
+ end
+
def has_label?(label)
page.within(element_selector_css(:labels_block)) do
element = find('span', text: label)
diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb
index 77afb3cfcba..7150098a00a 100644
--- a/qa/qa/resource/merge_request.rb
+++ b/qa/qa/resource/merge_request.rb
@@ -58,11 +58,15 @@ module QA
populate(:target, :source)
project.visit!
- Page::Project::Show.perform(&:new_merge_request)
+ Page::Project::Show.perform do |project|
+ project.wait_for_push
+ project.new_merge_request
+ end
Page::MergeRequest::New.perform do |page|
page.fill_title(@title)
page.fill_description(@description)
page.choose_milestone(@milestone) if @milestone
+ page.assign_to_me if @assignee == 'me'
labels.each do |label|
page.select_label(label)
end
diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb
index 7fd2ba25527..b706d6565d2 100644
--- a/qa/qa/runtime/browser.rb
+++ b/qa/qa/runtime/browser.rb
@@ -70,6 +70,13 @@ module QA
options.add_argument("disable-gpu")
end
+ # Use the same profile on QA runs if CHROME_REUSE_PROFILE is true.
+ # Useful to speed up local QA.
+ if QA::Runtime::Env.reuse_chrome_profile?
+ qa_profile_dir = ::File.expand_path('../../tmp/qa-profile', __dir__)
+ options.add_argument("user-data-dir=#{qa_profile_dir}")
+ end
+
# Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252
options.add_argument("disable-dev-shm-usage") if QA::Runtime::Env.running_in_ci?
diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb
index 3bc2b44ccd8..dae5aa3f794 100644
--- a/qa/qa/runtime/env.rb
+++ b/qa/qa/runtime/env.rb
@@ -30,6 +30,11 @@ module QA
enabled?(ENV['CHROME_HEADLESS'])
end
+ # set to 'true' to have Chrome use a fixed profile directory
+ def reuse_chrome_profile?
+ enabled?(ENV['CHROME_REUSE_PROFILE'], default: false)
+ end
+
def accept_insecure_certs?
enabled?(ENV['ACCEPT_INSECURE_CERTS'])
end
diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb
index d33947f41da..6ddd7dde2cf 100644
--- a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb
@@ -4,6 +4,8 @@ module QA
context 'Create' do
describe 'Merge request creation' do
it 'user creates a new merge request' do
+ gitlab_account_username = "@#{Runtime::User.username}"
+
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
@@ -27,6 +29,7 @@ module QA
merge_request.description = 'Great feature with milestone'
merge_request.project = current_project
merge_request.milestone = current_milestone
+ merge_request.assignee = 'me'
merge_request.labels.push(new_label)
end
@@ -34,6 +37,7 @@ module QA
expect(merge_request).to have_content('This is a merge request with a milestone')
expect(merge_request).to have_content('Great feature with milestone')
expect(merge_request).to have_content(/Opened [\w\s]+ ago/)
+ expect(merge_request).to have_assignee(gitlab_account_username)
expect(merge_request).to have_label(new_label.title)
end
diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb
index 5c72dab698c..80513650636 100644
--- a/spec/controllers/projects/commits_controller_spec.rb
+++ b/spec/controllers/projects/commits_controller_spec.rb
@@ -53,6 +53,12 @@ describe Projects::CommitsController do
it { is_expected.to respond_with(:not_found) }
end
+
+ context "branch with invalid format, valid file" do
+ let(:id) { 'branch with space/README.md' }
+
+ it { is_expected.to respond_with(:not_found) }
+ end
end
context "when the ref name ends in .atom" do
@@ -94,6 +100,30 @@ describe Projects::CommitsController do
end
end
end
+
+ describe "GET /commits/:id/signatures" do
+ render_views
+
+ before do
+ get(:signatures,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id,
+ format: :json)
+ end
+
+ context "valid branch" do
+ let(:id) { 'master' }
+
+ it { is_expected.to respond_with(:success) }
+ end
+
+ context "invalid branch format" do
+ let(:id) { 'some branch' }
+
+ it { is_expected.to respond_with(:not_found) }
+ end
+ end
end
context 'token authentication' do
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 02930edbf72..6240ab6d867 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -42,6 +42,8 @@ describe Projects::IssuesController do
it_behaves_like "issuables list meta-data", :issue
+ it_behaves_like 'set sort order from user preference'
+
it "returns index" do
get :index, namespace_id: project.namespace, project_id: project
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 7f15da859e5..a37a831ddbb 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -160,6 +160,8 @@ describe Projects::MergeRequestsController do
it_behaves_like "issuables list meta-data", :merge_request
+ it_behaves_like 'set sort order from user preference'
+
context 'when page param' do
let(:last_page) { project.merge_requests.page().total_pages }
let!(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index e8584846b56..7c505ee0d43 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -54,7 +54,8 @@ describe 'Database schema' do
user_agent_details: %w[subject_id],
users: %w[color_scheme_id created_by_id theme_id],
users_star_projects: %w[user_id],
- web_hooks: %w[service_id]
+ web_hooks: %w[service_id],
+ suggestions: %w[commit_id]
}.with_indifferent_access.freeze
context 'for table' do
diff --git a/spec/factories/suggestions.rb b/spec/factories/suggestions.rb
new file mode 100644
index 00000000000..307523cc061
--- /dev/null
+++ b/spec/factories/suggestions.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :suggestion do
+ relative_order 0
+ association :note, factory: :diff_note_on_merge_request
+ from_content " vars = {\n"
+ to_content " vars = [\n"
+
+ trait :unappliable do
+ from_content "foo"
+ to_content "foo"
+ end
+
+ trait :applied do
+ applied true
+ commit_id { RepoHelpers.sample_commit.id }
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index 328f96e6ed7..ba4806821f9 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -361,8 +361,14 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end
end
- it 'shows jump to next discussion button' do
- expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
+ it 'shows jump to next discussion button except on last discussion' do
+ wait_for_requests
+
+ all_discussion_replies = page.all('.discussion-reply-holder')
+
+ expect(all_discussion_replies.count).to eq(2)
+ expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(1)
+ expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(0)
end
it 'displays next discussion even if hidden' do
@@ -380,7 +386,13 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
page.find('.discussion-next-btn').click
end
- expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
+ page.all('.note-discussion').first do
+ expect(page.find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
+ end
+
+ page.all('.note-discussion').last do
+ expect(page.find('.discussion-with-resolve-btn')).not.to have_selector('.btn', text: 'Resolve discussion')
+ end
end
end
diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
new file mode 100644
index 00000000000..c19e299097e
--- /dev/null
+++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User comments on a diff', :js do
+ include MergeRequestDiffHelpers
+ include RepoHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:merge_request) do
+ create(:merge_request_with_diffs, source_project: project, target_project: project, source_branch: 'merge-test')
+ end
+ let(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+
+ visit(diffs_project_merge_request_path(project, merge_request))
+ end
+
+ context 'single suggestion note' do
+ it 'suggestion is presented' do
+ click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.diff-discussions') do
+ expect(page).to have_button('Apply suggestion')
+ expect(page).to have_content('Suggested change')
+ expect(page).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
+ expect(page).to have_content('# change to a comment')
+ end
+ end
+
+ it 'suggestion is appliable' do
+ click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.diff-discussions') do
+ expect(page).not_to have_content('Applied')
+
+ click_button('Apply suggestion')
+ wait_for_requests
+
+ expect(page).to have_content('Applied')
+ end
+ end
+ end
+
+ context 'multiple suggestions in a single note' do
+ it 'suggestions are presented' do
+ click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```\n```suggestion\n# or that\n```")
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.diff-discussions') do
+ suggestion_1 = page.all(:css, '.md-suggestion-diff')[0]
+ suggestion_2 = page.all(:css, '.md-suggestion-diff')[1]
+
+ expect(suggestion_1).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
+ expect(suggestion_1).to have_content('# change to a comment')
+
+ expect(suggestion_2).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
+ expect(suggestion_2).to have_content('# or that')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb
index f3cf3a282e5..66268355345 100644
--- a/spec/features/projects/files/user_browses_files_spec.rb
+++ b/spec/features/projects/files/user_browses_files_spec.rb
@@ -11,6 +11,7 @@ describe "User browses files" do
let(:user) { project.owner }
before do
+ stub_feature_flags(csslab: false)
sign_in(user)
end
diff --git a/spec/fixtures/api/schemas/entities/diff_line.json b/spec/fixtures/api/schemas/entities/diff_line.json
index 66e8b443e1b..9657004cd2d 100644
--- a/spec/fixtures/api/schemas/entities/diff_line.json
+++ b/spec/fixtures/api/schemas/entities/diff_line.json
@@ -8,7 +8,8 @@
"new_line": { "type": ["integer", "null"] },
"text": { "type": ["string"] },
"rich_text": { "type": ["string"] },
- "meta_data": { "type": ["object", "null"] }
+ "meta_data": { "type": ["object", "null"] },
+ "can_receive_suggestion": { "type": "boolean" }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json
index 35971d564d5..193ab6821a5 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json
@@ -119,7 +119,8 @@
"can_push_to_source_branch": { "type": "boolean" },
"rebase_path": { "type": ["string", "null"] },
"squash": { "type": "boolean" },
- "test_reports_path": { "type": ["string", "null"] }
+ "test_reports_path": { "type": ["string", "null"] },
+ "can_receive_suggestion": { "type": "boolean" }
},
"additionalProperties": false
}
diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb
index cba0d93e144..f405268d198 100644
--- a/spec/helpers/sorting_helper_spec.rb
+++ b/spec/helpers/sorting_helper_spec.rb
@@ -21,7 +21,11 @@ describe SortingHelper do
describe '#issuable_sort_direction_button' do
before do
- allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: {}))
+ allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: { label_name: 'test_label' }))
+ end
+
+ it 'keeps label filter param' do
+ expect(issuable_sort_direction_button('created_date')).to include('label_name=test_label')
end
it 'returns icon with sort-highest when sort is created_date' do
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
index 46f72214831..9d55c615450 100644
--- a/spec/javascripts/api_spec.js
+++ b/spec/javascripts/api_spec.js
@@ -333,6 +333,40 @@ describe('Api', () => {
});
});
+ describe('user', () => {
+ it('fetches single user', done => {
+ const userId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`;
+ mock.onGet(expectedUrl).reply(200, {
+ name: 'testuser',
+ });
+
+ Api.user(userId)
+ .then(({ data }) => {
+ expect(data.name).toBe('testuser');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('user status', () => {
+ it('fetches single user status', done => {
+ const userId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`;
+ mock.onGet(expectedUrl).reply(200, {
+ message: 'testmessage',
+ });
+
+ Api.userStatus(userId)
+ .then(({ data }) => {
+ expect(data.message).toBe('testmessage');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar';
diff --git a/spec/javascripts/boards/components/issue_due_date_spec.js b/spec/javascripts/boards/components/issue_due_date_spec.js
index 9e49330c052..054cf8c5b7d 100644
--- a/spec/javascripts/boards/components/issue_due_date_spec.js
+++ b/spec/javascripts/boards/components/issue_due_date_spec.js
@@ -49,10 +49,11 @@ describe('Issue Due Date component', () => {
it('should render month and day for other dates', () => {
date.setDate(date.getDate() + 17);
vm = createComponent(date);
+ const today = new Date();
+ const isDueInCurrentYear = today.getFullYear() === date.getFullYear();
+ const format = isDueInCurrentYear ? 'mmm d' : 'mmm d, yyyy';
- expect(vm.$el.querySelector('time').textContent.trim()).toEqual(
- dateFormat(date, 'mmm d', true),
- );
+ expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, format, true));
});
it('should contain the correct `.text-danger` css class for overdue issue', () => {
diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js
index c28e41ec175..14fff9223f4 100644
--- a/spec/javascripts/boards/mock_data.js
+++ b/spec/javascripts/boards/mock_data.js
@@ -1,5 +1,11 @@
import BoardService from '~/boards/services/board_service';
+export const boardObj = {
+ id: 1,
+ name: 'test',
+ milestone_id: null,
+};
+
export const listObj = {
id: 300,
position: 0,
@@ -40,6 +46,12 @@ export const BoardsMockData = {
},
],
},
+ '/test/issue-boards/milestones.json': [
+ {
+ id: 1,
+ title: 'test',
+ },
+ ],
},
POST: {
'/test/-/boards/1/lists': listObj,
@@ -70,3 +82,60 @@ export const mockBoardService = (opts = {}) => {
boardId,
});
};
+
+export const mockAssigneesList = [
+ {
+ id: 2,
+ name: 'Terrell Graham',
+ username: 'monserrate.gleichner',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/598fd02741ac58b88854a99d16704309?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/monserrate.gleichner',
+ path: '/monserrate.gleichner',
+ },
+ {
+ id: 12,
+ name: 'Susy Johnson',
+ username: 'tana_harvey',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e021a7b0f3e4ae53b5068d487e68c031?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/tana_harvey',
+ path: '/tana_harvey',
+ },
+ {
+ id: 20,
+ name: 'Conchita Eichmann',
+ username: 'juliana_gulgowski',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/c43c506cb6fd7b37017d3b54b94aa937?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/juliana_gulgowski',
+ path: '/juliana_gulgowski',
+ },
+ {
+ id: 6,
+ name: 'Bryce Turcotte',
+ username: 'melynda',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/cc2518f2c6f19f8fac49e1a5ee092a9b?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/melynda',
+ path: '/melynda',
+ },
+ {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/root',
+ path: '/root',
+ },
+];
+
+export const mockMilestone = {
+ id: 1,
+ state: 'active',
+ title: 'Milestone title',
+ description: 'Harum corporis aut consequatur quae dolorem error sequi quia.',
+ start_date: '2018-01-01',
+ due_date: '2019-12-31',
+};
diff --git a/spec/javascripts/diffs/components/diff_content_spec.js b/spec/javascripts/diffs/components/diff_content_spec.js
index c25f6167163..d688560a260 100644
--- a/spec/javascripts/diffs/components/diff_content_spec.js
+++ b/spec/javascripts/diffs/components/diff_content_spec.js
@@ -17,6 +17,7 @@ describe('DiffContent', () => {
current_user: {
can_create_note: false,
},
+ preview_note_path: 'path/to/preview',
};
vm = mountComponentWithStore(Component, {
diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js
index 44313caba29..c1e9f791925 100644
--- a/spec/javascripts/diffs/mock_data/diff_discussions.js
+++ b/spec/javascripts/diffs/mock_data/diff_discussions.js
@@ -487,8 +487,19 @@ export default {
],
},
diff_discussion: true,
- truncated_diff_lines:
- '<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new noteable_line"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new noteable_line"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n',
+ truncated_diff_lines: [
+ {
+ text: 'line',
+ rich_text:
+ '<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new noteable_line"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new noteable_line"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n',
+ can_receive_suggestion: true,
+ line_code: '6f209374f7e565f771b95720abf46024c41d1885_1_1',
+ type: 'new',
+ old_line: null,
+ new_line: 1,
+ meta_data: null,
+ },
+ ],
};
export const imageDiffDiscussions = [
diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js
index 55ce19927e0..033b5e86dbe 100644
--- a/spec/javascripts/diffs/store/actions_spec.js
+++ b/spec/javascripts/diffs/store/actions_spec.js
@@ -26,7 +26,9 @@ import actions, {
toggleTreeOpen,
scrollToFile,
toggleShowTreeList,
+ renderFileForDiscussionId,
} from '~/diffs/store/actions';
+import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
import axios from '~/lib/utils/axios_utils';
import mockDiffFile from 'spec/diffs/mock_data/diff_file';
@@ -735,4 +737,63 @@ describe('DiffsStoreActions', () => {
expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true);
});
});
+
+ describe('renderFileForDiscussionId', () => {
+ const rootState = {
+ notes: {
+ discussions: [
+ {
+ id: '123',
+ diff_file: {
+ file_hash: 'HASH',
+ },
+ },
+ {
+ id: '456',
+ diff_file: {
+ file_hash: 'HASH',
+ },
+ },
+ ],
+ },
+ };
+ let commit;
+ let $emit;
+ let scrollToElement;
+ const state = ({ collapsed, renderIt }) => ({
+ diffFiles: [
+ {
+ file_hash: 'HASH',
+ collapsed,
+ renderIt,
+ },
+ ],
+ });
+
+ beforeEach(() => {
+ commit = jasmine.createSpy('commit');
+ scrollToElement = spyOnDependency(actions, 'scrollToElement').and.stub();
+ $emit = spyOn(eventHub, '$emit');
+ });
+
+ it('renders and expands file for the given discussion id', () => {
+ const localState = state({ collapsed: true, renderIt: false });
+
+ renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
+
+ expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]);
+ expect($emit).toHaveBeenCalledTimes(1);
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ });
+
+ it('jumps to discussion on already rendered and expanded file', () => {
+ const localState = state({ collapsed: false, renderIt: true });
+
+ renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
+
+ expect(commit).not.toHaveBeenCalled();
+ expect($emit).toHaveBeenCalledTimes(1);
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/javascripts/diffs/store/mutations_spec.js b/spec/javascripts/diffs/store/mutations_spec.js
index 23e8761bc55..f3449bec6ec 100644
--- a/spec/javascripts/diffs/store/mutations_spec.js
+++ b/spec/javascripts/diffs/store/mutations_spec.js
@@ -277,6 +277,87 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
});
+ it('updates existing discussion', () => {
+ const diffPosition = {
+ base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
+ head_sha: 'b921914f9a834ac47e6fd9420f78db0f83559130',
+ new_line: null,
+ new_path: '500-lines-4.txt',
+ old_line: 5,
+ old_path: '500-lines-4.txt',
+ start_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
+ };
+
+ const state = {
+ latestDiff: true,
+ diffFiles: [
+ {
+ file_hash: 'ABC',
+ parallel_diff_lines: [
+ {
+ left: {
+ line_code: 'ABC_1',
+ discussions: [],
+ },
+ right: {
+ line_code: 'ABC_1',
+ discussions: [],
+ },
+ },
+ ],
+ highlighted_diff_lines: [
+ {
+ line_code: 'ABC_1',
+ discussions: [],
+ },
+ ],
+ },
+ ],
+ };
+ const discussion = {
+ id: 1,
+ line_code: 'ABC_1',
+ diff_discussion: true,
+ resolvable: true,
+ original_position: diffPosition,
+ position: diffPosition,
+ diff_file: {
+ file_hash: state.diffFiles[0].file_hash,
+ },
+ };
+
+ const diffPositionByLineCode = {
+ ABC_1: diffPosition,
+ };
+
+ mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
+ discussion,
+ diffPositionByLineCode,
+ });
+
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1);
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1);
+ expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]);
+
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1);
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
+
+ mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
+ discussion: {
+ ...discussion,
+ resolved: true,
+ notes: ['test'],
+ },
+ diffPositionByLineCode,
+ });
+
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].notes.length).toBe(1);
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].notes.length).toBe(1);
+
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].resolved).toBe(true);
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].resolved).toBe(true);
+ });
+
it('should add legacy discussions to the given line', () => {
const diffPosition = {
base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
@@ -356,10 +437,12 @@ describe('DiffsStoreMutations', () => {
{
id: 1,
line_code: 'ABC_1',
+ notes: [],
},
{
id: 2,
line_code: 'ABC_1',
+ notes: [],
},
],
},
@@ -376,10 +459,12 @@ describe('DiffsStoreMutations', () => {
{
id: 1,
line_code: 'ABC_1',
+ notes: [],
},
{
id: 2,
line_code: 'ABC_1',
+ notes: [],
},
],
},
diff --git a/spec/javascripts/image_diff/helpers/badge_helper_spec.js b/spec/javascripts/image_diff/helpers/badge_helper_spec.js
index 8ea05203d00..b3001d45e3c 100644
--- a/spec/javascripts/image_diff/helpers/badge_helper_spec.js
+++ b/spec/javascripts/image_diff/helpers/badge_helper_spec.js
@@ -61,6 +61,10 @@ describe('badge helper', () => {
expect(buttonEl).toBeDefined();
});
+ it('should add badge classes', () => {
+ expect(buttonEl.className).toContain('badge badge-pill');
+ });
+
it('should set the badge text', () => {
expect(buttonEl.innerText).toEqual(badgeText);
});
diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js
index 6adc19bdd51..acb5e024acd 100644
--- a/spec/javascripts/lib/utils/users_cache_spec.js
+++ b/spec/javascripts/lib/utils/users_cache_spec.js
@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache';
describe('UsersCache', () => {
const dummyUsername = 'win';
- const dummyUser = 'has a farm';
+ const dummyUserId = 123;
+ const dummyUser = { name: 'has a farm', username: 'farmer' };
+ const dummyUserStatus = 'my status';
beforeEach(() => {
UsersCache.internalStorage = {};
@@ -135,4 +137,110 @@ describe('UsersCache', () => {
.catch(done.fail);
});
});
+
+ describe('retrieveById', () => {
+ let apiSpy;
+
+ beforeEach(() => {
+ spyOn(Api, 'user').and.callFake(id => apiSpy(id));
+ });
+
+ it('stores and returns data from API call if cache is empty', done => {
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.resolve({
+ data: dummyUser,
+ });
+ };
+
+ UsersCache.retrieveById(dummyUserId)
+ .then(user => {
+ expect(user).toBe(dummyUser);
+ expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns undefined if Ajax call fails and cache is empty', done => {
+ const dummyError = new Error('server exploded');
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.reject(dummyError);
+ };
+
+ UsersCache.retrieveById(dummyUserId)
+ .then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`))
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('makes no Ajax call if matching data exists', done => {
+ UsersCache.internalStorage[dummyUserId] = dummyUser;
+ apiSpy = () => fail(new Error('expected no Ajax call!'));
+
+ UsersCache.retrieveById(dummyUserId)
+ .then(user => {
+ expect(user).toBe(dummyUser);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('retrieveStatusById', () => {
+ let apiSpy;
+
+ beforeEach(() => {
+ spyOn(Api, 'userStatus').and.callFake(id => apiSpy(id));
+ });
+
+ it('stores and returns data from API call if cache is empty', done => {
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.resolve({
+ data: dummyUserStatus,
+ });
+ };
+
+ UsersCache.retrieveStatusById(dummyUserId)
+ .then(userStatus => {
+ expect(userStatus).toBe(dummyUserStatus);
+ expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns undefined if Ajax call fails and cache is empty', done => {
+ const dummyError = new Error('server exploded');
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.reject(dummyError);
+ };
+
+ UsersCache.retrieveStatusById(dummyUserId)
+ .then(userStatus => fail(`Received unexpected user: ${JSON.stringify(userStatus)}`))
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('makes no Ajax call if matching data exists', done => {
+ UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus };
+ apiSpy = () => fail(new Error('expected no Ajax call!'));
+
+ UsersCache.retrieveStatusById(dummyUserId)
+ .then(userStatus => {
+ expect(userStatus).toBe(dummyUserStatus);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/notes/components/note_edited_text_spec.js b/spec/javascripts/notes/components/note_edited_text_spec.js
index e0b991c32ec..e4c8d954d50 100644
--- a/spec/javascripts/notes/components/note_edited_text_spec.js
+++ b/spec/javascripts/notes/components/note_edited_text_spec.js
@@ -39,7 +39,7 @@ describe('note_edited_text', () => {
});
it('should render provided user information', () => {
- const authorLink = vm.$el.querySelector('.js-vue-author');
+ const authorLink = vm.$el.querySelector('.js-user-link');
expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js
index 379780f43a0..6d1a7ef370f 100644
--- a/spec/javascripts/notes/components/note_header_spec.js
+++ b/spec/javascripts/notes/components/note_header_spec.js
@@ -42,6 +42,9 @@ describe('note_header component', () => {
it('should render user information', () => {
expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root');
expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual('/root');
+ expect(vm.$el.querySelector('.note-header-info a').dataset.userId).toEqual('1');
+ expect(vm.$el.querySelector('.note-header-info a').dataset.username).toEqual('root');
+ expect(vm.$el.querySelector('.note-header-info a').classList).toContain('js-user-link');
});
it('should render timestamp link', () => {
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index ab9c52346d6..e4d29a3860c 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -83,6 +83,7 @@ describe('noteable_discussion component', () => {
it('expands next unresolved discussion', done => {
const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
discussion2.resolved = false;
+ discussion2.active = true;
discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
window.mrTabs.currentAction = 'show';
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index ad0e793b915..7ae45c40c28 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -305,6 +305,7 @@ export const discussionMock = {
],
individual_note: false,
resolvable: true,
+ active: true,
};
export const loggedOutnoteableData = {
@@ -1173,6 +1174,7 @@ export const discussion1 = {
id: 'abc1',
resolvable: true,
resolved: false,
+ active: true,
diff_file: {
file_path: 'about.md',
},
@@ -1209,6 +1211,7 @@ export const discussion2 = {
id: 'abc2',
resolvable: true,
resolved: false,
+ active: true,
diff_file: {
file_path: 'README.md',
},
@@ -1226,6 +1229,7 @@ export const discussion2 = {
export const discussion3 = {
id: 'abc3',
resolvable: true,
+ active: true,
resolved: false,
diff_file: {
file_path: 'README.md',
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index 24c2b3e6570..2e3cd5e8f36 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -124,7 +124,7 @@ describe('Actions Notes Store', () => {
{ discussionId: discussionMock.id },
{ notes: [discussionMock] },
[{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }],
- [],
+ [{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }],
done,
);
});
diff --git a/spec/javascripts/registry/components/app_spec.js b/spec/javascripts/registry/components/app_spec.js
index 92ff960277a..67118ac03a5 100644
--- a/spec/javascripts/registry/components/app_spec.js
+++ b/spec/javascripts/registry/components/app_spec.js
@@ -1,37 +1,30 @@
-import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import registry from '~/registry/components/app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { TEST_HOST } from 'spec/test_constants';
import { reposServerResponse } from '../mock_data';
describe('Registry List', () => {
+ const Component = Vue.extend(registry);
let vm;
- let Component;
+ let mock;
beforeEach(() => {
- Component = Vue.extend(registry);
+ mock = new MockAdapter(axios);
});
afterEach(() => {
+ mock.restore();
vm.$destroy();
});
describe('with data', () => {
- const interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }),
- );
- };
-
beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- vm = mountComponent(Component, { endpoint: 'foo' });
- });
+ mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, reposServerResponse);
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` });
});
it('should render a list of repos', done => {
@@ -64,9 +57,9 @@ describe('Registry List', () => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
- expect(vm.$el.querySelector('.js-toggle-repo i').className).toEqual(
- 'fa fa-chevron-up',
- );
+ expect(
+ vm.$el.querySelector('.js-toggle-repo use').getAttribute('xlink:href'),
+ ).toContain('angle-up');
done();
});
});
@@ -76,21 +69,10 @@ describe('Registry List', () => {
});
describe('without data', () => {
- const interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify([]), {
- status: 200,
- }),
- );
- };
-
beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- vm = mountComponent(Component, { endpoint: 'foo' });
- });
+ mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` });
});
it('should render empty message', done => {
@@ -109,21 +91,10 @@ describe('Registry List', () => {
});
describe('while loading data', () => {
- const interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }),
- );
- };
-
beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- vm = mountComponent(Component, { endpoint: 'foo' });
- });
+ mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` });
});
it('should render a loading spinner', done => {
diff --git a/spec/javascripts/registry/components/collapsible_container_spec.js b/spec/javascripts/registry/components/collapsible_container_spec.js
index 256a242f784..a3f7ff76dc7 100644
--- a/spec/javascripts/registry/components/collapsible_container_spec.js
+++ b/spec/javascripts/registry/components/collapsible_container_spec.js
@@ -1,14 +1,24 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import store from '~/registry/stores';
-import { repoPropsData } from '../mock_data';
+import * as types from '~/registry/stores/mutation_types';
+
+import { repoPropsData, registryServerResponse, reposServerResponse } from '../mock_data';
describe('collapsible registry container', () => {
let vm;
- let Component;
+ let mock;
+ const Component = Vue.extend(collapsibleComponent);
beforeEach(() => {
- Component = Vue.extend(collapsibleComponent);
+ mock = new MockAdapter(axios);
+
+ mock.onGet(repoPropsData.tagsPath).replyOnce(200, registryServerResponse, {});
+
+ store.commit(types.SET_REPOS_LIST, reposServerResponse);
+
vm = new Component({
store,
propsData: {
@@ -18,24 +28,23 @@ describe('collapsible registry container', () => {
});
afterEach(() => {
+ mock.restore();
vm.$destroy();
});
describe('toggle', () => {
it('should be closed by default', () => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
- expect(vm.$el.querySelector('.container-image-head i').className).toEqual(
- 'fa fa-chevron-right',
- );
+ expect(vm.iconName).toEqual('angle-right');
});
it('should be open when user clicks on closed repo', done => {
vm.$el.querySelector('.js-toggle-repo').click();
+
Vue.nextTick(() => {
- expect(vm.$el.querySelector('.container-image-tags')).toBeDefined();
- expect(vm.$el.querySelector('.container-image-head i').className).toEqual(
- 'fa fa-chevron-up',
- );
+ expect(vm.$el.querySelector('.container-image-tags')).not.toBeNull();
+ expect(vm.iconName).toEqual('angle-up');
+
done();
});
});
@@ -45,12 +54,12 @@ describe('collapsible registry container', () => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
- expect(vm.$el.querySelector('.container-image-head i').className).toEqual(
- 'fa fa-chevron-right',
- );
- done();
+ setTimeout(() => {
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
+ expect(vm.iconName).toEqual('angle-right');
+ done();
+ });
});
});
});
@@ -58,7 +67,7 @@ describe('collapsible registry container', () => {
describe('delete repo', () => {
it('should be possible to delete a repo', () => {
- expect(vm.$el.querySelector('.js-remove-repo')).toBeDefined();
+ expect(vm.$el.querySelector('.js-remove-repo')).not.toBeNull();
});
});
});
diff --git a/spec/javascripts/registry/stores/actions_spec.js b/spec/javascripts/registry/stores/actions_spec.js
index bc4c444655a..c9aa82dba90 100644
--- a/spec/javascripts/registry/stores/actions_spec.js
+++ b/spec/javascripts/registry/stores/actions_spec.js
@@ -1,42 +1,34 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/stores/actions';
import * as types from '~/registry/stores/mutation_types';
+import state from '~/registry/stores/state';
+import { TEST_HOST } from 'spec/test_constants';
import testAction from '../../helpers/vuex_action_helper';
import {
- defaultState,
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';
-Vue.use(VueResource);
-
describe('Actions Registry Store', () => {
- let interceptor;
let mockedState;
+ let mock;
beforeEach(() => {
- mockedState = defaultState;
+ mockedState = state();
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
});
- describe('server requests', () => {
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
- });
+ afterEach(() => {
+ mock.restore();
+ });
+ describe('server requests', () => {
describe('fetchRepos', () => {
beforeEach(() => {
- interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }),
- );
- };
-
- Vue.http.interceptors.push(interceptor);
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
it('should set receveived repos', done => {
@@ -56,23 +48,15 @@ describe('Actions Registry Store', () => {
});
describe('fetchList', () => {
+ let repo;
beforeEach(() => {
- interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(registryServerResponse), {
- status: 200,
- }),
- );
- };
+ mockedState.repos = parsedReposServerResponse;
+ [, repo] = mockedState.repos;
- Vue.http.interceptors.push(interceptor);
+ mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});
it('should set received list', done => {
- mockedState.repos = parsedReposServerResponse;
-
- const repo = mockedState.repos[1];
-
testAction(
actions.fetchList,
{ repo },
diff --git a/spec/javascripts/user_popovers_spec.js b/spec/javascripts/user_popovers_spec.js
new file mode 100644
index 00000000000..6cf8dd81b36
--- /dev/null
+++ b/spec/javascripts/user_popovers_spec.js
@@ -0,0 +1,66 @@
+import initUserPopovers from '~/user_popovers';
+import UsersCache from '~/lib/utils/users_cache';
+
+describe('User Popovers', () => {
+ const selector = '.js-user-link';
+
+ const dummyUser = { name: 'root' };
+ const dummyUserStatus = { message: 'active' };
+
+ const triggerEvent = (eventName, el) => {
+ const event = document.createEvent('MouseEvents');
+ event.initMouseEvent(eventName, true, true, window);
+
+ el.dispatchEvent(event);
+ };
+
+ beforeEach(() => {
+ setFixtures(`
+ <a href="/root" data-user-id="1" class="js-user-link" data-username="root" data-original-title="" title="">
+ Root
+ </a>
+ `);
+
+ const usersCacheSpy = () => Promise.resolve(dummyUser);
+ spyOn(UsersCache, 'retrieveById').and.callFake(userId => usersCacheSpy(userId));
+
+ const userStatusCacheSpy = () => Promise.resolve(dummyUserStatus);
+ spyOn(UsersCache, 'retrieveStatusById').and.callFake(userId => userStatusCacheSpy(userId));
+
+ initUserPopovers(document.querySelectorAll('.js-user-link'));
+ });
+
+ it('Should Show+Hide Popover on mouseenter and mouseleave', done => {
+ triggerEvent('mouseenter', document.querySelector(selector));
+
+ setTimeout(() => {
+ const shownPopover = document.querySelector('.popover');
+
+ expect(shownPopover).not.toBeNull();
+
+ expect(shownPopover.innerHTML).toContain(dummyUser.name);
+ expect(UsersCache.retrieveById).toHaveBeenCalledWith('1');
+
+ triggerEvent('mouseleave', document.querySelector(selector));
+
+ setTimeout(() => {
+ // After Mouse leave it should be hidden now
+ expect(document.querySelector('.popover')).toBeNull();
+ done();
+ });
+ }, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible
+ });
+
+ it('Should Not show a popover on short mouse over', done => {
+ triggerEvent('mouseenter', document.querySelector(selector));
+
+ setTimeout(() => {
+ expect(document.querySelector('.popover')).toBeNull();
+ expect(UsersCache.retrieveById).not.toHaveBeenCalledWith('1');
+
+ triggerEvent('mouseleave', document.querySelector(selector));
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js
new file mode 100644
index 00000000000..9eac75fac96
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js
@@ -0,0 +1,114 @@
+import Vue from 'vue';
+
+import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { mockAssigneesList } from 'spec/boards/mock_data';
+
+const createComponent = (assignees = mockAssigneesList, cssClass = '') => {
+ const Component = Vue.extend(IssueAssignees);
+
+ return mountComponent(Component, {
+ assignees,
+ cssClass,
+ });
+};
+
+describe('IssueAssigneesComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('data', () => {
+ it('returns default data props', () => {
+ expect(vm.maxVisibleAssignees).toBe(2);
+ expect(vm.maxAssigneeAvatars).toBe(3);
+ expect(vm.maxAssignees).toBe(99);
+ });
+ });
+
+ describe('computed', () => {
+ describe('countOverLimit', () => {
+ it('should return difference between assignees count and maxVisibleAssignees', () => {
+ expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
+ });
+ });
+
+ describe('assigneesToShow', () => {
+ it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => {
+ expect(vm.assigneesToShow.length).toBe(2);
+ });
+
+ it('should return all assignees as it is when count less than maxAssigneeAvatars', () => {
+ vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
+
+ expect(vm.assigneesToShow.length).toBe(3);
+ });
+ });
+
+ describe('assigneesCounterTooltip', () => {
+ it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => {
+ expect(vm.assigneesCounterTooltip).toBe('3 more assignees');
+ });
+ });
+
+ describe('shouldRenderAssigneesCounter', () => {
+ it('should return `false` when assignees count less than maxAssigneeAvatars', () => {
+ vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
+
+ expect(vm.shouldRenderAssigneesCounter).toBe(false);
+ });
+
+ it('should return `true` when assignees count more than maxAssigneeAvatars', () => {
+ expect(vm.shouldRenderAssigneesCounter).toBe(true);
+ });
+ });
+
+ describe('assigneeCounterLabel', () => {
+ it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
+ expect(vm.assigneeCounterLabel).toBe('+3');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('avatarUrlTitle', () => {
+ it('returns string containing alt text for assignee avatar', () => {
+ expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component root element with class `issue-assignees`', () => {
+ expect(vm.$el.classList.contains('issue-assignees')).toBe(true);
+ });
+
+ it('renders assignee avatars', () => {
+ expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2);
+ });
+
+ it('renders assignee tooltips', () => {
+ const tooltipText = vm.$el
+ .querySelectorAll('.user-avatar-link')[0]
+ .querySelector('.js-assignee-tooltip').innerText;
+
+ expect(tooltipText).toContain('Assignee');
+ expect(tooltipText).toContain('Terrell Graham');
+ expect(tooltipText).toContain('@monserrate.gleichner');
+ });
+
+ it('renders additional assignees count', () => {
+ const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
+
+ expect(avatarCounterEl.innerText.trim()).toBe('+3');
+ expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js
new file mode 100644
index 00000000000..8fca2637326
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js
@@ -0,0 +1,234 @@
+import Vue from 'vue';
+
+import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { mockMilestone } from 'spec/boards/mock_data';
+
+const createComponent = (milestone = mockMilestone) => {
+ const Component = Vue.extend(IssueMilestone);
+
+ return mountComponent(Component, {
+ milestone,
+ });
+};
+
+describe('IssueMilestoneComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('isMilestoneStarted', () => {
+ it('should return `false` when milestoneStart prop is not defined', done => {
+ const vmStartUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStartUndefined.isMilestoneStarted).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStartUndefined.$destroy();
+ });
+
+ it('should return `true` when milestone start date is past current date', done => {
+ const vmStarted = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '1990-07-22',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStarted.isMilestoneStarted).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStarted.$destroy();
+ });
+ });
+
+ describe('isMilestonePastDue', () => {
+ it('should return `false` when milestoneDue prop is not defined', done => {
+ const vmDueUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDueUndefined.isMilestonePastDue).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDueUndefined.$destroy();
+ });
+
+ it('should return `true` when milestone due is past current date', done => {
+ const vmPastDue = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: '1990-07-22',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmPastDue.isMilestonePastDue).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmPastDue.$destroy();
+ });
+ });
+
+ describe('milestoneDatesAbsolute', () => {
+ it('returns string containing absolute milestone due date', () => {
+ expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)');
+ });
+
+ it('returns string containing absolute milestone start date when due date is not present', done => {
+ const vmDueUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDueUndefined.$destroy();
+ });
+
+ it('returns empty string when both milestone start and due dates are not present', done => {
+ const vmDatesUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '',
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDatesUndefined.milestoneDatesAbsolute).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDatesUndefined.$destroy();
+ });
+ });
+
+ describe('milestoneDatesHuman', () => {
+ it('returns string containing milestone due date when date is yet to be due', done => {
+ const vmFuture = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: `${new Date().getFullYear() + 10}-01-01`,
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmFuture.milestoneDatesHuman).toContain('years remaining');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmFuture.$destroy();
+ });
+
+ it('returns string containing milestone start date when date has already started and due date is not present', done => {
+ const vmStarted = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '1990-07-22',
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStarted.milestoneDatesHuman).toContain('Started');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStarted.$destroy();
+ });
+
+ it('returns string containing milestone start date when date is yet to start and due date is not present', done => {
+ const vmStarts = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: `${new Date().getFullYear() + 10}-01-01`,
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStarts.milestoneDatesHuman).toContain('Starts');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStarts.$destroy();
+ });
+
+ it('returns empty string when milestone start and due dates are not present', done => {
+ const vmDatesUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '',
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDatesUndefined.milestoneDatesHuman).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDatesUndefined.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component root element with class `issue-milestone-details`', () => {
+ expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true);
+ });
+
+ it('renders milestone icon', () => {
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock');
+ });
+
+ it('renders milestone title', () => {
+ expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title);
+ });
+
+ it('renders milestone tooltip', () => {
+ expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain(
+ mockMilestone.title,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js
index 59613faa49f..e733a95288e 100644
--- a/spec/javascripts/vue_shared/components/markdown/header_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js
@@ -28,6 +28,7 @@ describe('Markdown field header component', () => {
'Add a numbered list',
'Add a task list',
'Add a table',
+ 'Insert suggestion',
'Go full screen',
];
const elements = vm.$el.querySelectorAll('.toolbar-btn');
@@ -93,4 +94,18 @@ describe('Markdown field header component', () => {
'| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |',
);
});
+
+ it('renders suggestion template', () => {
+ vm.lineContent = 'Some content';
+
+ expect(vm.mdSuggestion).toEqual('```suggestion\n{text}\n```');
+ });
+
+ it('does not render suggestion button if `canSuggest` is set to false', () => {
+ vm.canSuggest = false;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.qa-suggestion-btn')).toBe(null);
+ });
+ });
});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js
new file mode 100644
index 00000000000..8187b3204b1
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import SuggestionDiffHeaderComponent from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
+
+const MOCK_DATA = {
+ canApply: true,
+ isApplied: false,
+ helpPagePath: 'path_to_docs',
+};
+
+describe('Suggestion Diff component', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const Component = Vue.extend(SuggestionDiffHeaderComponent);
+
+ return new Component({
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(done => {
+ vm = createComponent(MOCK_DATA);
+ Vue.nextTick(done);
+ });
+
+ describe('init', () => {
+ it('renders a suggestion header', () => {
+ const header = vm.$el.querySelector('.qa-suggestion-diff-header');
+
+ expect(header).not.toBeNull();
+ expect(header.innerHTML.includes('Suggested change')).toBe(true);
+ });
+
+ it('renders an apply button', () => {
+ const applyBtn = vm.$el.querySelector('.qa-apply-btn');
+
+ expect(applyBtn).not.toBeNull();
+ expect(applyBtn.innerHTML.includes('Apply suggestion')).toBe(true);
+ });
+
+ it('does not render an apply button if `canApply` is set to false', () => {
+ const props = Object.assign(MOCK_DATA, { canApply: false });
+
+ vm = createComponent(props);
+
+ expect(vm.$el.querySelector('.qa-apply-btn')).toBeNull();
+ });
+ });
+
+ describe('applySuggestion', () => {
+ it('emits when the apply button is clicked', () => {
+ const props = Object.assign(MOCK_DATA, { canApply: true });
+
+ vm = createComponent(props);
+ spyOn(vm, '$emit');
+ vm.applySuggestion();
+
+ expect(vm.$emit).toHaveBeenCalled();
+ });
+
+ it('does not emit when the canApply is set to false', () => {
+ spyOn(vm, '$emit');
+ vm.canApply = false;
+ vm.applySuggestion();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js
new file mode 100644
index 00000000000..d4ed8f2f7a4
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js
@@ -0,0 +1,79 @@
+import Vue from 'vue';
+import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion_diff.vue';
+
+const MOCK_DATA = {
+ canApply: true,
+ newLines: [
+ { content: 'Line 1\n', lineNumber: 1 },
+ { content: 'Line 2\n', lineNumber: 2 },
+ { content: 'Line 3\n', lineNumber: 3 },
+ ],
+ fromLine: 1,
+ fromContent: 'Old content',
+ suggestion: {
+ id: 1,
+ },
+ helpPagePath: 'path_to_docs',
+};
+
+describe('Suggestion Diff component', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(SuggestionDiffComponent);
+
+ vm = new Component({
+ propsData: MOCK_DATA,
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ describe('init', () => {
+ it('renders a suggestion header', () => {
+ expect(vm.$el.querySelector('.qa-suggestion-diff-header')).not.toBeNull();
+ });
+
+ it('renders a diff table', () => {
+ expect(vm.$el.querySelector('table.md-suggestion-diff')).not.toBeNull();
+ });
+
+ it('renders the oldLineNumber', () => {
+ const fromLine = vm.$el.querySelector('.qa-old-diff-line-number').innerHTML;
+
+ expect(parseInt(fromLine, 10)).toBe(vm.fromLine);
+ });
+
+ it('renders the oldLineContent', () => {
+ const fromContent = vm.$el.querySelector('.line_content.old').innerHTML;
+
+ expect(fromContent.includes(vm.fromContent)).toBe(true);
+ });
+
+ it('renders the contents of newLines', () => {
+ const newLines = vm.$el.querySelectorAll('.line_holder.new');
+
+ newLines.forEach((line, i) => {
+ expect(newLines[i].innerHTML.includes(vm.newLines[i].content)).toBe(true);
+ });
+ });
+
+ it('renders a line number for each line', () => {
+ const newLineNumbers = vm.$el.querySelectorAll('.qa-new-diff-line-number');
+
+ newLineNumbers.forEach((line, i) => {
+ expect(newLineNumbers[i].innerHTML.includes(vm.newLines[i].lineNumber)).toBe(true);
+ });
+ });
+ });
+
+ describe('applySuggestion', () => {
+ it('emits apply event when applySuggestion is called', () => {
+ const callback = () => {};
+ spyOn(vm, '$emit');
+ vm.applySuggestion(callback);
+
+ expect(vm.$emit).toHaveBeenCalledWith('apply', { suggestionId: vm.suggestion.id, callback });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js
new file mode 100644
index 00000000000..ab1b747c360
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
+
+const MOCK_DATA = {
+ fromLine: 1,
+ fromContent: 'Old content',
+ suggestions: [],
+ noteHtml: `
+ <div class="suggestion">
+ <div class="line">Suggestion 1</div>
+ </div>
+
+ <div class="suggestion">
+ <div class="line">Suggestion 2</div>
+ </div>
+ `,
+ isApplied: false,
+ helpPagePath: 'path_to_docs',
+};
+
+const generateLine = content => {
+ const line = document.createElement('div');
+ line.className = 'line';
+ line.innerHTML = content;
+
+ return line;
+};
+
+const generateMockLines = () => {
+ const line1 = generateLine('Line 1');
+ const line2 = generateLine('Line 2');
+ const line3 = generateLine('Line 3');
+ const container = document.createElement('div');
+
+ container.appendChild(line1);
+ container.appendChild(line2);
+ container.appendChild(line3);
+
+ return container;
+};
+
+describe('Suggestion component', () => {
+ let vm;
+ let extractedLines;
+ let diffTable;
+
+ beforeEach(done => {
+ const Component = Vue.extend(SuggestionsComponent);
+
+ vm = new Component({
+ propsData: MOCK_DATA,
+ }).$mount();
+
+ extractedLines = vm.extractNewLines(generateMockLines());
+ diffTable = vm.generateDiff(extractedLines).$mount().$el;
+
+ spyOn(vm, 'renderSuggestions');
+ vm.renderSuggestions();
+ Vue.nextTick(done);
+ });
+
+ describe('mounted', () => {
+ it('renders a flash container', () => {
+ expect(vm.$el.querySelector('.flash-container')).not.toBeNull();
+ });
+
+ it('renders a container for suggestions', () => {
+ expect(vm.$refs.container).not.toBeNull();
+ });
+
+ it('renders suggestions', () => {
+ expect(vm.renderSuggestions).toHaveBeenCalled();
+ expect(vm.$el.innerHTML.includes('Suggestion 1')).toBe(true);
+ expect(vm.$el.innerHTML.includes('Suggestion 2')).toBe(true);
+ });
+ });
+
+ describe('extractNewLines', () => {
+ it('extracts suggested lines', () => {
+ const expectedReturn = [
+ { content: 'Line 1\n', lineNumber: 1 },
+ { content: 'Line 2\n', lineNumber: 2 },
+ { content: 'Line 3\n', lineNumber: 3 },
+ ];
+
+ expect(vm.extractNewLines(generateMockLines())).toEqual(expectedReturn);
+ });
+
+ it('increments line number for each extracted line', () => {
+ expect(extractedLines[0].lineNumber).toEqual(1);
+ expect(extractedLines[1].lineNumber).toEqual(2);
+ expect(extractedLines[2].lineNumber).toEqual(3);
+ });
+
+ it('returns empty array if no lines are found', () => {
+ const el = document.createElement('div');
+
+ expect(vm.extractNewLines(el)).toEqual([]);
+ });
+ });
+
+ describe('generateDiff', () => {
+ it('generates a diff table', () => {
+ expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
+ });
+
+ it('generates a diff table that contains contents of `oldLineContent`', () => {
+ expect(diffTable.innerHTML.includes(vm.fromContent)).toBe(true);
+ });
+
+ it('generates a diff table that contains contents the suggested lines', () => {
+ extractedLines.forEach((line, i) => {
+ expect(diffTable.innerHTML.includes(extractedLines[i].content)).toBe(true);
+ });
+ });
+
+ it('generates a diff table with the correct line number for each suggested line', () => {
+ const lines = diffTable.getElementsByClassName('qa-new-diff-line-number');
+
+ expect([...lines][0].innerHTML).toBe('1');
+ expect([...lines][1].innerHTML).toBe('2');
+ expect([...lines][2].innerHTML).toBe('3');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
index 5c4aa7cf844..c5045afc5b0 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
+import defaultAvatarUrl from '~/../images/no_avatar.png';
const DEFAULT_PROPS = {
size: 99,
@@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() {
});
});
+ describe('Initialization without src', function() {
+ beforeEach(function() {
+ vm = mountComponent(UserAvatarImage);
+ });
+
+ it('should have default avatar image', function() {
+ const imageElement = vm.$el.querySelector('img');
+
+ expect(imageElement.getAttribute('src')).toBe(defaultAvatarUrl);
+ });
+ });
+
describe('dynamic tooltip content', () => {
const props = DEFAULT_PROPS;
const slots = {
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
index 0151ad23ba2..f2472fd377c 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() {
describe('username', function() {
it('should not render avatar image tooltip', function() {
- expect(
- this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(),
- ).toEqual('');
+ expect(this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip')).toBeNull();
});
it('should render username prop in <span>', function() {
diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
new file mode 100644
index 00000000000..1578b0f81f9
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
@@ -0,0 +1,133 @@
+import Vue from 'vue';
+import userPopover from '~/vue_shared/components/user_popover/user_popover.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const DEFAULT_PROPS = {
+ loaded: true,
+ user: {
+ username: 'root',
+ name: 'Administrator',
+ location: 'Vienna',
+ bio: null,
+ organization: null,
+ status: null,
+ },
+};
+
+const UserPopover = Vue.extend(userPopover);
+
+describe('User Popover Component', () => {
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+ <a href="/root" data-user-id="1" class="js-user-link" title="testuser">
+ Root
+ </a>
+ `);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('Empty', () => {
+ beforeEach(() => {
+ vm = mountComponent(UserPopover, {
+ target: document.querySelector('.js-user-link'),
+ user: {
+ name: null,
+ username: null,
+ location: null,
+ bio: null,
+ organization: null,
+ status: null,
+ },
+ });
+ });
+
+ it('should return skeleton loaders', () => {
+ expect(vm.$el.querySelectorAll('.animation-container').length).toBe(4);
+ });
+ });
+
+ describe('basic data', () => {
+ it('should show basic fields', () => {
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.name);
+ expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.username);
+ expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.location);
+ });
+ });
+
+ describe('job data', () => {
+ it('should show only bio if no organization is available', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Engineer';
+
+ vm = mountComponent(UserPopover, {
+ ...testProps,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('Engineer');
+ });
+
+ it('should show only organization if no bio is available', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.organization = 'GitLab';
+
+ vm = mountComponent(UserPopover, {
+ ...testProps,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('GitLab');
+ });
+
+ it('should have full job line when we have bio and organization', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Engineer';
+ testProps.user.organization = 'GitLab';
+
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('Engineer at GitLab');
+ });
+ });
+
+ describe('status data', () => {
+ it('should show only message', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.status = { message: 'Hello World' };
+
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('Hello World');
+ });
+
+ it('should show message and emoji', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.status = { emoji: 'basketball_player', message: 'Hello World' };
+
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ status: { emoji: 'basketball_player', message: 'Hello World' },
+ });
+
+ expect(vm.$el.textContent).toContain('Hello World');
+ expect(vm.$el.innerHTML).toContain('<gl-emoji data-name="basketball_player"');
+ });
+ });
+});
diff --git a/spec/lib/banzai/filter/suggestion_filter_spec.rb b/spec/lib/banzai/filter/suggestion_filter_spec.rb
new file mode 100644
index 00000000000..55a141bf315
--- /dev/null
+++ b/spec/lib/banzai/filter/suggestion_filter_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Banzai::Filter::SuggestionFilter do
+ include FilterSpecHelper
+
+ let(:input) { "<pre class='code highlight js-syntax-highlight suggestion'><code>foo\n</code></pre>" }
+ let(:default_context) do
+ { suggestions_filter_enabled: true }
+ end
+
+ it 'includes `js-render-suggestion` class' do
+ doc = filter(input, default_context)
+ result = doc.css('code').first
+
+ expect(result[:class]).to include('js-render-suggestion')
+ end
+
+ it 'includes no `js-render-suggestion` when feature disabled' do
+ stub_feature_flags(diff_suggestions: false)
+
+ doc = filter(input, default_context)
+ result = doc.css('code').first
+
+ expect(result[:class]).to be_nil
+ end
+
+ it 'includes no `js-render-suggestion` when filter is disabled' do
+ doc = filter(input)
+ result = doc.css('code').first
+
+ expect(result[:class]).to be_nil
+ end
+end
diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb
index 334d29a5368..1e8a44b4549 100644
--- a/spec/lib/banzai/filter/user_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb
@@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'includes default classes' do
doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member has-tooltip'
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member'
end
context 'when a project is not specified' do
diff --git a/spec/lib/banzai/suggestions_parser_spec.rb b/spec/lib/banzai/suggestions_parser_spec.rb
new file mode 100644
index 00000000000..79658d710ce
--- /dev/null
+++ b/spec/lib/banzai/suggestions_parser_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Banzai::SuggestionsParser do
+ describe '.parse' do
+ it 'returns a list of suggestion contents' do
+ markdown = <<-MARKDOWN.strip_heredoc
+ ```suggestion
+ foo
+ bar
+ ```
+
+ ```
+ nothing
+ ```
+
+ ```suggestion
+ xpto
+ baz
+ ```
+
+ ```thing
+ this is not a suggestion, it's a thing
+ ```
+ MARKDOWN
+
+ expect(described_class.parse(markdown)).to eq([" foo\n bar",
+ " xpto\n baz"])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb
new file mode 100644
index 00000000000..d1d64574627
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Gitlab::BackgroundMigration::PopulateMergeRequestMetricsWithEventsDataImproved, :migration, schema: 20181204154019 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:users) { table(:users) }
+ let(:events) { table(:events) }
+
+ let(:user) { users.create!(email: 'test@example.com', projects_limit: 100, username: 'test') }
+
+ let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') }
+ let(:merge_requests) { table(:merge_requests) }
+
+ def create_merge_request(id, params = {})
+ params.merge!(id: id,
+ target_project_id: project.id,
+ target_branch: 'master',
+ source_project_id: project.id,
+ source_branch: 'mr name',
+ title: "mr name#{id}")
+
+ merge_requests.create(params)
+ end
+
+ def create_merge_request_event(id, params = {})
+ params.merge!(id: id,
+ project_id: project.id,
+ author_id: user.id,
+ target_type: 'MergeRequest')
+
+ events.create(params)
+ end
+
+ describe '#perform' do
+ it 'creates and updates closed and merged events' do
+ timestamp = Time.new('2018-01-01 12:00:00').utc
+
+ create_merge_request(1)
+ create_merge_request_event(1, target_id: 1, action: 3, updated_at: timestamp)
+ create_merge_request_event(2, target_id: 1, action: 3, updated_at: timestamp + 10.seconds)
+
+ create_merge_request_event(3, target_id: 1, action: 7, updated_at: timestamp)
+ create_merge_request_event(4, target_id: 1, action: 7, updated_at: timestamp + 10.seconds)
+
+ subject.perform(1, 1)
+
+ merge_request = MergeRequest.first
+
+ expect(merge_request.metrics).to have_attributes(latest_closed_by_id: user.id,
+ latest_closed_at: timestamp + 10.seconds,
+ merged_by_id: user.id)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb
index 12f4b9dc624..61d78f86b51 100644
--- a/spec/lib/gitlab/ci/config/entry/global_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb
@@ -161,7 +161,8 @@ describe Gitlab::Ci::Config::Entry::Global do
variables: { 'VAR' => 'value' },
ignore: false,
after_script: ['make clean'],
- only: { refs: %w[branches tags] } },
+ only: { refs: %w[branches tags] },
+ except: {} },
spinach: { name: :spinach,
before_script: [],
script: %w[spinach],
@@ -173,7 +174,8 @@ describe Gitlab::Ci::Config::Entry::Global do
variables: {},
ignore: false,
after_script: ['make clean'],
- only: { refs: %w[branches tags] } }
+ only: { refs: %w[branches tags] },
+ except: {} }
)
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index c1f4a060063..8e32cede3b5 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -259,7 +259,8 @@ describe Gitlab::Ci::Config::Entry::Job do
stage: 'test',
ignore: false,
after_script: %w[cleanup],
- only: { refs: %w[branches tags] })
+ only: { refs: %w[branches tags] },
+ except: {})
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
index 2a753408f54..1a2c30d3571 100644
--- a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
@@ -68,13 +68,15 @@ describe Gitlab::Ci::Config::Entry::Jobs do
commands: 'rspec',
ignore: false,
stage: 'test',
- only: { refs: %w[branches tags] } },
+ only: { refs: %w[branches tags] },
+ except: {} },
spinach: { name: :spinach,
script: %w[spinach],
commands: 'spinach',
ignore: false,
stage: 'test',
- only: { refs: %w[branches tags] } })
+ only: { refs: %w[branches tags] },
+ except: {} })
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index bae5b21c26f..72abd8973cb 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -37,6 +37,7 @@ notes:
- events
- system_note_metadata
- note_diff_file
+- suggestions
label_links:
- target
- label
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index 39e0a17a307..62970bd8cb6 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -249,6 +249,27 @@ describe Gitlab::UrlBlocker do
end
end
end
+
+ context 'when ascii_only is true' do
+ it 'returns true for unicode domain' do
+ expect(described_class.blocked_url?('https://𝕘itⅼαƄ.com/foo/foo.bar', ascii_only: true)).to be true
+ end
+
+ it 'returns true for unicode tld' do
+ expect(described_class.blocked_url?('https://gitlab.ᴄοm/foo/foo.bar', ascii_only: true)).to be true
+ end
+
+ it 'returns true for unicode path' do
+ expect(described_class.blocked_url?('https://gitlab.com/𝒇οο/𝒇οο.Ƅαꮁ', ascii_only: true)).to be true
+ end
+
+ it 'returns true for IDNA deviations' do
+ expect(described_class.blocked_url?('https://mißile.com/foo/foo.bar', ascii_only: true)).to be true
+ expect(described_class.blocked_url?('https://miςςile.com/foo/foo.bar', ascii_only: true)).to be true
+ expect(described_class.blocked_url?('https://git‍lab.com/foo/foo.bar', ascii_only: true)).to be true
+ expect(described_class.blocked_url?('https://git‌lab.com/foo/foo.bar', ascii_only: true)).to be true
+ end
+ end
end
describe '#validate_hostname!' do
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index deb19fe1a4b..2a09f581f68 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -117,6 +117,7 @@ describe Gitlab::UsageData do
releases
remote_mirrors
snippets
+ suggestions
todos
uploads
web_hooks
diff --git a/spec/migrations/populate_mr_metrics_with_events_data_spec.rb b/spec/migrations/populate_mr_metrics_with_events_data_spec.rb
new file mode 100644
index 00000000000..291a52b904d
--- /dev/null
+++ b/spec/migrations/populate_mr_metrics_with_events_data_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20181204154019_populate_mr_metrics_with_events_data.rb')
+
+describe PopulateMrMetricsWithEventsData, :migration, :sidekiq do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') }
+ let(:merge_requests) { table(:merge_requests) }
+
+ def create_merge_request(id)
+ params = {
+ id: id,
+ target_project_id: project.id,
+ target_branch: 'master',
+ source_project_id: project.id,
+ source_branch: 'mr name',
+ title: "mr name#{id}"
+ }
+
+ merge_requests.create!(params)
+ end
+
+ it 'correctly schedules background migrations' do
+ create_merge_request(1)
+ create_merge_request(2)
+ create_merge_request(3)
+
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(8.minutes, 1, 2)
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(16.minutes, 3, 3)
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 8624f0daa4d..40ce8ab736a 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -318,6 +318,24 @@ describe DiffNote do
end
end
+ describe '#supports_suggestion?' do
+ context 'when noteable does not support suggestions' do
+ it 'returns false' do
+ allow(subject.noteable).to receive(:supports_suggestion?) { false }
+
+ expect(subject.supports_suggestion?).to be(false)
+ end
+ end
+
+ context 'when line is not suggestible' do
+ it 'returns false' do
+ allow_any_instance_of(Gitlab::Diff::Line).to receive(:suggestible?) { false }
+
+ expect(subject.supports_suggestion?).to be(false)
+ end
+ end
+ end
+
describe "image diff notes" do
let(:path) { "files/images/any_image.png" }
diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb
index 097b1bb30dc..99d3ab41b97 100644
--- a/spec/models/members/project_member_spec.rb
+++ b/spec/models/members/project_member_spec.rb
@@ -11,10 +11,6 @@ describe ProjectMember do
it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) }
end
- describe 'modules' do
- it { is_expected.to include_module(Gitlab::ShellAdapter) }
- end
-
describe '.access_level_roles' do
it 'returns Gitlab::Access.options' do
expect(described_class.access_level_roles).to eq(Gitlab::Access.options)
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 1c85411dc3b..5e63f14b720 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1897,7 +1897,7 @@ describe Project do
end
end
- describe '#latest_successful_builds_for' do
+ describe '#latest_successful_builds_for and #latest_successful_build_for' do
def create_pipeline(status = 'success')
create(:ci_pipeline, project: project,
sha: project.commit.sha,
@@ -1919,14 +1919,16 @@ describe Project do
it 'gives the latest builds from latest pipeline' do
pipeline1 = create_pipeline
pipeline2 = create_pipeline
- build1_p2 = create_build(pipeline2, 'test')
create_build(pipeline1, 'test')
create_build(pipeline1, 'test2')
+ build1_p2 = create_build(pipeline2, 'test')
build2_p2 = create_build(pipeline2, 'test2')
latest_builds = project.latest_successful_builds_for
+ single_build = project.latest_successful_build_for(build1_p2.name)
expect(latest_builds).to contain_exactly(build2_p2, build1_p2)
+ expect(single_build).to eq(build1_p2)
end
end
@@ -1936,16 +1938,22 @@ describe Project do
context 'standalone pipeline' do
it 'returns builds for ref for default_branch' do
builds = project.latest_successful_builds_for
+ single_build = project.latest_successful_build_for(build.name)
expect(builds).to contain_exactly(build)
+ expect(single_build).to eq(build)
end
- it 'returns empty relation if the build cannot be found' do
+ it 'returns empty relation if the build cannot be found for #latest_successful_builds_for' do
builds = project.latest_successful_builds_for('TAIL')
expect(builds).to be_kind_of(ActiveRecord::Relation)
expect(builds).to be_empty
end
+
+ it 'returns exception if the build cannot be found for #latest_successful_build_for' do
+ expect { project.latest_successful_build_for(build.name, 'TAIL') }.to raise_error(ActiveRecord::RecordNotFound)
+ end
end
context 'with some pending pipeline' do
@@ -1954,9 +1962,11 @@ describe Project do
end
it 'gives the latest build from latest pipeline' do
- latest_build = project.latest_successful_builds_for
+ latest_builds = project.latest_successful_builds_for
+ last_single_build = project.latest_successful_build_for(build.name)
- expect(latest_build).to contain_exactly(build)
+ expect(latest_builds).to contain_exactly(build)
+ expect(last_single_build).to eq(build)
end
end
end
@@ -4092,6 +4102,29 @@ describe Project do
end
end
+ describe '#object_pool_params' do
+ let(:project) { create(:project, :repository, :public) }
+
+ subject { project.object_pool_params }
+
+ before do
+ stub_application_setting(hashed_storage_enabled: true)
+ end
+
+ context 'when the objects cannot be pooled' do
+ let(:project) { create(:project, :repository, :private) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when a pool is created' do
+ it 'returns that pool repository' do
+ expect(subject).not_to be_empty
+ expect(subject[:pool_repository]).to be_persisted
+ end
+ end
+ end
+
describe '#git_objects_poolable?' do
subject { project }
diff --git a/spec/models/suggestion_spec.rb b/spec/models/suggestion_spec.rb
new file mode 100644
index 00000000000..cafc725dddb
--- /dev/null
+++ b/spec/models/suggestion_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Suggestion do
+ let(:suggestion) { create(:suggestion) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:note) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:note) }
+
+ context 'when suggestion is applied' do
+ before do
+ allow(subject).to receive(:applied?).and_return(true)
+ end
+
+ it { is_expected.to validate_presence_of(:commit_id) }
+ end
+ end
+
+ describe '#appliable?' do
+ context 'when note does not support suggestions' do
+ it 'returns false' do
+ expect_next_instance_of(DiffNote) do |note|
+ allow(note).to receive(:supports_suggestion?) { false }
+ end
+
+ expect(suggestion).not_to be_appliable
+ end
+ end
+
+ context 'when patch is already applied' do
+ let(:suggestion) { create(:suggestion, :applied) }
+
+ it 'returns false' do
+ expect(suggestion).not_to be_appliable
+ end
+ end
+
+ context 'when merge request is not opened' do
+ let(:merge_request) { create(:merge_request, :merged) }
+ let(:note) do
+ create(:diff_note_on_merge_request, project: merge_request.project,
+ noteable: merge_request)
+ end
+
+ let(:suggestion) { create(:suggestion, note: note) }
+
+ it 'returns false' do
+ expect(suggestion).not_to be_appliable
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 8770365c893..cd4e480ca64 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -586,6 +586,136 @@ describe API::Jobs do
end
end
+ describe 'GET id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name' do
+ context 'when job has artifacts' do
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) }
+ let(:artifact) { 'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif' }
+ let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:public_builds) { true }
+
+ before do
+ stub_artifacts_object_storage
+ job.success
+
+ project.update(visibility_level: visibility_level,
+ public_builds: public_builds)
+
+ get_artifact_file(artifact)
+ end
+
+ context 'when user is anonymous' do
+ let(:api_user) { nil }
+
+ context 'when project is public' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:public_builds) { true }
+
+ it 'allows to access artifacts' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers.to_h)
+ .to include('Content-Type' => 'application/json',
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'when project is public with builds access disabled' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:public_builds) { false }
+
+ it 'rejects access to artifacts' do
+ expect(response).to have_gitlab_http_status(403)
+ expect(json_response).to have_key('message')
+ expect(response.headers.to_h)
+ .not_to include('Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'when project is private' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
+ let(:public_builds) { true }
+
+ it 'rejects access and hides existence of artifacts' do
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response).to have_key('message')
+ expect(response.headers.to_h)
+ .not_to include('Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+ end
+
+ context 'when user is authorized' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
+ let(:public_builds) { true }
+
+ it 'returns a specific artifact file for a valid path' do
+ expect(Gitlab::Workhorse)
+ .to receive(:send_artifacts_entry)
+ .and_call_original
+
+ get_artifact_file(artifact)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers.to_h)
+ .to include('Content-Type' => 'application/json',
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'with branch name containing slash' do
+ before do
+ pipeline.reload
+ pipeline.update(ref: 'improve/awesome',
+ sha: project.commit('improve/awesome').sha)
+ end
+
+ it 'returns a specific artifact file for a valid path' do
+ get_artifact_file(artifact, 'improve/awesome')
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers.to_h)
+ .to include('Content-Type' => 'application/json',
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'non-existing job' do
+ shared_examples 'not found' do
+ it { expect(response).to have_gitlab_http_status(:not_found) }
+ end
+
+ context 'has no such ref' do
+ before do
+ get_artifact_file('some/artifact', 'wrong-ref')
+ end
+
+ it_behaves_like 'not found'
+ end
+
+ context 'has no such job' do
+ before do
+ get_artifact_file('some/artifact', pipeline.ref, 'wrong-job-name')
+ end
+
+ it_behaves_like 'not found'
+ end
+ end
+ end
+
+ context 'when job does not have artifacts' do
+ let(:job) { create(:ci_build, pipeline: pipeline, user: api_user) }
+
+ it 'does not return job artifact file' do
+ get_artifact_file('some/artifact')
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ def get_artifact_file(artifact_path, ref = pipeline.ref, job_name = job.name)
+ get api("/projects/#{project.id}/jobs/artifacts/#{ref}/raw/#{artifact_path}", api_user), job: job_name
+ end
+ end
+
describe 'GET /projects/:id/jobs/:job_id/trace' do
before do
get api("/projects/#{project.id}/jobs/#{job.id}/trace", api_user)
diff --git a/spec/requests/api/suggestions_spec.rb b/spec/requests/api/suggestions_spec.rb
new file mode 100644
index 00000000000..8e9f737fbd5
--- /dev/null
+++ b/spec/requests/api/suggestions_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Suggestions do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs)
+ end
+
+ let(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ describe "PUT /suggestions/:id/apply" do
+ let(:url) { "/suggestions/#{suggestion.id}/apply" }
+
+ context 'when successfully applies patch' do
+ let(:suggestion) do
+ create(:suggestion, note: diff_note,
+ from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
+ to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
+ end
+
+ it 'returns 200 with json content' do
+ project.add_maintainer(user)
+
+ put api(url, user), id: suggestion.id
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response)
+ .to include('id', 'from_original_line', 'to_original_line',
+ 'from_line', 'to_line', 'appliable', 'applied',
+ 'from_content', 'to_content')
+ end
+ end
+
+ context 'when not able to apply patch' do
+ let(:suggestion) do
+ create(:suggestion, :unappliable, note: diff_note)
+ end
+
+ it 'returns 400 with json content' do
+ project.add_maintainer(user)
+
+ put api(url, user), id: suggestion.id
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response).to eq({ 'message' => 'Suggestion is not appliable' })
+ end
+ end
+
+ context 'when unauthorized' do
+ let(:suggestion) do
+ create(:suggestion, note: diff_note,
+ from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
+ to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
+ end
+
+ it 'returns 403 with json content' do
+ project.add_reporter(user)
+
+ put api(url, user), id: suggestion.id
+
+ expect(response).to have_gitlab_http_status(403)
+ expect(json_response).to eq({ 'message' => '403 Forbidden' })
+ end
+ end
+ end
+end
diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb
index 06d9d3657e6..f6fa2a794f6 100644
--- a/spec/serializers/issue_board_entity_spec.rb
+++ b/spec/serializers/issue_board_entity_spec.rb
@@ -3,21 +3,40 @@
require 'spec_helper'
describe IssueBoardEntity do
- let(:project) { create(:project) }
- let(:resource) { create(:issue, project: project) }
- let(:user) { create(:user) }
-
- let(:request) { double('request', current_user: user) }
+ let(:project) { create(:project) }
+ let(:resource) { create(:issue, project: project) }
+ let(:user) { create(:user) }
+ let(:milestone) { create(:milestone, project: project) }
+ let(:label) { create(:label, project: project, title: 'Test Label') }
+ let(:request) { double('request', current_user: user) }
subject { described_class.new(resource, request: request).as_json }
it 'has basic attributes' do
expect(subject).to include(:id, :iid, :title, :confidential, :due_date, :project_id, :relative_position,
- :project, :labels)
+ :labels, :assignees, project: hash_including(:id, :path))
end
it 'has path and endpoints' do
expect(subject).to include(:reference_path, :real_path, :issue_sidebar_endpoint,
:toggle_subscription_endpoint, :assignable_labels_endpoint)
end
+
+ it 'has milestone attributes' do
+ resource.milestone = milestone
+
+ expect(subject).to include(milestone: hash_including(:id, :title))
+ end
+
+ it 'has assignee attributes' do
+ resource.assignees = [user]
+
+ expect(subject).to include(assignees: array_including(hash_including(:id, :name, :username, :avatar_url)))
+ end
+
+ it 'has label attributes' do
+ resource.labels = [label]
+
+ expect(subject).to include(labels: array_including(hash_including(:id, :title, :color, :description, :text_color, :priority)))
+ end
end
diff --git a/spec/serializers/suggestion_entity_spec.rb b/spec/serializers/suggestion_entity_spec.rb
new file mode 100644
index 00000000000..047571f161c
--- /dev/null
+++ b/spec/serializers/suggestion_entity_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe SuggestionEntity do
+ include RepoHelpers
+
+ let(:user) { create(:user) }
+ let(:request) { double('request', current_user: user) }
+ let(:suggestion) { create(:suggestion) }
+ let(:entity) { described_class.new(suggestion, request: request) }
+
+ subject { entity.as_json }
+
+ it 'exposes correct attributes' do
+ expect(subject).to include(:id, :from_original_line, :to_original_line, :from_line,
+ :to_line, :appliable, :applied, :from_content, :to_content)
+ end
+
+ it 'exposes current user abilities' do
+ expect(subject[:current_user]).to include(:can_apply)
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index ccc6b0ef1c7..ffa47d527f7 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -810,6 +810,95 @@ describe Ci::CreatePipelineService do
end
end
end
+
+ context "when config uses regular expression for only keyword" do
+ let(:config) do
+ {
+ build: {
+ stage: 'build',
+ script: 'echo',
+ only: ["/^#{ref_name}$/"]
+ }
+ }
+ end
+
+ context 'when merge request is specified' do
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: ref_name,
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(pipeline).not_to be_persisted
+
+ expect(pipeline.errors[:base])
+ .to eq(['No stages / jobs for this pipeline.'])
+ end
+ end
+ end
+
+ context "when config uses variables for only keyword" do
+ let(:config) do
+ {
+ build: {
+ stage: 'build',
+ script: 'echo',
+ only: {
+ variables: %w($CI)
+ }
+ }
+ }
+ end
+
+ context 'when merge request is specified' do
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: ref_name,
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(pipeline).not_to be_persisted
+
+ expect(pipeline.errors[:base])
+ .to eq(['No stages / jobs for this pipeline.'])
+ end
+ end
+ end
+
+ context "when config has 'except: [tags]'" do
+ let(:config) do
+ {
+ build: {
+ stage: 'build',
+ script: 'echo',
+ except: ['tags']
+ }
+ }
+ end
+
+ context 'when merge request is specified' do
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: ref_name,
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(pipeline).not_to be_persisted
+
+ expect(pipeline.errors[:base])
+ .to eq(['No stages / jobs for this pipeline.'])
+ end
+ end
+ end
end
context 'when source is web' do
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 533dcdcd6cd..fd9bff46a06 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -20,6 +20,29 @@ describe Notes::UpdateService do
@note.reload
end
+ context 'suggestions' do
+ it 'refreshes note suggestions' do
+ markdown = <<-MARKDOWN.strip_heredoc
+ ```suggestion
+ foo
+ ```
+
+ ```suggestion
+ bar
+ ```
+ MARKDOWN
+
+ suggestion = create(:suggestion)
+ note = suggestion.note
+
+ expect { described_class.new(project, user, note: markdown).execute(note) }
+ .to change { note.suggestions.count }.from(1).to(2)
+
+ expect(note.suggestions.order(:relative_order).map(&:to_content))
+ .to eq([" foo\n", " bar\n"])
+ end
+ end
+
context 'todos' do
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb
index b69977c812a..458cb8f1f31 100644
--- a/spec/services/preview_markdown_service_spec.rb
+++ b/spec/services/preview_markdown_service_spec.rb
@@ -19,6 +19,31 @@ describe PreviewMarkdownService do
end
end
+ describe 'suggestions' do
+ let(:params) { { text: "```suggestion\nfoo\n```", preview_suggestions: preview_suggestions } }
+ let(:service) { described_class.new(project, user, params) }
+
+ context 'when preview markdown param is present' do
+ let(:preview_suggestions) { true }
+
+ it 'returns users referenced in text' do
+ result = service.execute
+
+ expect(result[:suggestions]).to eq(['foo'])
+ end
+ end
+
+ context 'when preview markdown param is not present' do
+ let(:preview_suggestions) { false }
+
+ it 'returns users referenced in text' do
+ result = service.execute
+
+ expect(result[:suggestions]).to eq([])
+ end
+ end
+ end
+
context 'new note with quick actions' do
let(:issue) { create(:issue, project: project) }
let(:params) do
diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb
new file mode 100644
index 00000000000..3a483717756
--- /dev/null
+++ b/spec/services/suggestions/apply_service_spec.rb
@@ -0,0 +1,229 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Suggestions::ApplyService do
+ include ProjectForksHelper
+
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user, :commit_email) }
+
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs)
+ end
+
+ let(:suggestion) do
+ create(:suggestion, note: diff_note,
+ from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
+ to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
+ end
+
+ subject { described_class.new(user) }
+
+ context 'patch is appliable' do
+ let(:expected_content) do
+ <<-CONTENT.strip_heredoc
+ require 'fileutils'
+ require 'open3'
+
+ module Popen
+ extend self
+
+ def popen(cmd, path=nil)
+ unless cmd.is_a?(Array)
+ raise RuntimeError, 'Explosion'
+ # explosion?
+ end
+
+ path ||= Dir.pwd
+
+ vars = {
+ "PWD" => path
+ }
+
+ options = {
+ chdir: path
+ }
+
+ unless File.directory?(path)
+ FileUtils.mkdir_p(path)
+ end
+
+ @cmd_output = ""
+ @cmd_status = 0
+
+ Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ @cmd_output << stdout.read
+ @cmd_output << stderr.read
+ @cmd_status = wait_thr.value.exitstatus
+ end
+
+ return @cmd_output, @cmd_status
+ end
+ end
+ CONTENT
+ end
+
+ context 'non-fork project' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let!(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates the file with the new contents' do
+ subject.execute(suggestion)
+
+ blob = project.repository.blob_at_branch(merge_request.source_branch,
+ position.new_path)
+
+ expect(blob.data).to eq(expected_content)
+ end
+
+ it 'returns success status' do
+ result = subject.execute(suggestion)
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ it 'updates suggestion applied and commit_id columns' do
+ expect { subject.execute(suggestion) }
+ .to change(suggestion, :applied)
+ .from(false).to(true)
+ .and change(suggestion, :commit_id)
+ .from(nil)
+ end
+
+ it 'created commit has users email and name' do
+ subject.execute(suggestion)
+
+ commit = project.repository.commit
+
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ expect(commit.author_name).to eq(user.name)
+ end
+ end
+
+ context 'fork-project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ let(:forked_project) do
+ fork_project_with_submodules(project, user)
+ end
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_branch: 'conflict-resolvable-fork', source_project: forked_project,
+ target_branch: 'conflict-start', target_project: project)
+ end
+
+ let!(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates file in the source project' do
+ expect(Files::UpdateService).to receive(:new)
+ .with(merge_request.source_project, user, anything)
+ .and_call_original
+
+ subject.execute(suggestion)
+ end
+ end
+ end
+
+ context 'no permission' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ context 'user cannot write in project repo' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'returns error' do
+ result = subject.execute(suggestion)
+
+ expect(result).to eq(message: "You are not allowed to push into this branch",
+ status: :error)
+ end
+ end
+ end
+
+ context 'patch is not appliable' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'suggestion was already applied' do
+ it 'returns success status' do
+ result = subject.execute(suggestion)
+
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'note is outdated' do
+ before do
+ allow(diff_note).to receive(:active?) { false }
+ end
+
+ it 'returns error message' do
+ result = subject.execute(suggestion)
+
+ expect(result).to eq(message: 'Suggestion is not appliable',
+ status: :error)
+ end
+ end
+
+ context 'suggestion was already applied' do
+ before do
+ suggestion.update!(applied: true, commit_id: 'sha')
+ end
+
+ it 'returns error message' do
+ result = subject.execute(suggestion)
+
+ expect(result).to eq(message: 'Suggestion is not appliable',
+ status: :error)
+ end
+ end
+ end
+end
diff --git a/spec/services/suggestions/create_service_spec.rb b/spec/services/suggestions/create_service_spec.rb
new file mode 100644
index 00000000000..f1142c88a69
--- /dev/null
+++ b/spec/services/suggestions/create_service_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Suggestions::CreateService do
+ let(:project_with_repo) { create(:project, :repository) }
+ let(:merge_request) do
+ create(:merge_request, source_project: project_with_repo,
+ target_project: project_with_repo)
+ end
+
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 14,
+ diff_refs: merge_request.diff_refs)
+ end
+
+ let(:markdown) do
+ <<-MARKDOWN.strip_heredoc
+ ```suggestion
+ foo
+ bar
+ ```
+
+ ```
+ nothing
+ ```
+
+ ```suggestion
+ xpto
+ baz
+ ```
+
+ ```thing
+ this is not a suggestion, it's a thing
+ ```
+ MARKDOWN
+ end
+
+ subject { described_class.new(note) }
+
+ describe '#execute' do
+ context 'should not try to parse suggestions' do
+ context 'when not a diff note for merge requests' do
+ let(:note) do
+ create(:diff_note_on_commit, project: project_with_repo,
+ note: markdown)
+ end
+
+ it 'does not try to parse suggestions' do
+ expect(Banzai::SuggestionsParser).not_to receive(:parse)
+
+ subject.execute
+ end
+ end
+
+ context 'when diff note is not for text' do
+ let(:note) do
+ create(:diff_note_on_merge_request, project: project_with_repo,
+ noteable: merge_request,
+ position: position,
+ note: markdown)
+ end
+
+ it 'does not try to parse suggestions' do
+ allow(note).to receive(:on_text?) { false }
+
+ expect(Banzai::SuggestionsParser).not_to receive(:parse)
+
+ subject.execute
+ end
+ end
+ end
+
+ context 'should create suggestions' do
+ let(:note) do
+ create(:diff_note_on_merge_request, project: project_with_repo,
+ noteable: merge_request,
+ position: position,
+ note: markdown)
+ end
+
+ context 'single line suggestions' do
+ it 'persists suggestion records' do
+ expect { subject.execute }
+ .to change { note.suggestions.count }
+ .from(0)
+ .to(2)
+ end
+
+ it 'persists original from_content lines and suggested lines' do
+ subject.execute
+
+ suggestions = note.suggestions.order(:relative_order)
+
+ suggestion_1 = suggestions.first
+ suggestion_2 = suggestions.last
+
+ expect(suggestion_1).to have_attributes(from_content: " vars = {\n",
+ to_content: " foo\n bar\n")
+
+ expect(suggestion_2).to have_attributes(from_content: " vars = {\n",
+ to_content: " xpto\n baz\n")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb
index 922f3df144d..42a086d58d2 100644
--- a/spec/support/features/discussion_comments_shared_example.rb
+++ b/spec/support/features/discussion_comments_shared_example.rb
@@ -178,6 +178,16 @@ shared_examples 'discussion comments' do |resource_name|
let(:note_id) { find("#{comments_selector} .note:first-child", match: :first)['data-note-id'] }
let(:reply_id) { find("#{comments_selector} .note:last-child", match: :first)['data-note-id'] }
+ it 'can be replied to after resolving' do
+ click_button "Resolve discussion"
+ wait_for_requests
+
+ refresh
+ wait_for_requests
+
+ submit_reply('to reply or not reply')
+ end
+
it 'shows resolved discussion when toggled' do
submit_reply('a')
diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb
index 086a345dca8..89c5ec7a718 100644
--- a/spec/support/helpers/javascript_fixtures_helpers.rb
+++ b/spec/support/helpers/javascript_fixtures_helpers.rb
@@ -6,6 +6,13 @@ module JavaScriptFixturesHelpers
FIXTURE_PATH = 'spec/javascripts/fixtures'.freeze
+ def self.included(base)
+ base.around do |example|
+ # pick an arbitrary date from the past, so tests are not time dependent
+ Timecop.freeze(Time.utc(2015, 7, 3, 10)) { example.run }
+ end
+ end
+
# Public: Removes all fixture files from given directory
#
# directory_name - directory of the fixtures (relative to FIXTURE_PATH)
diff --git a/spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb b/spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb
new file mode 100644
index 00000000000..b34948be670
--- /dev/null
+++ b/spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb
@@ -0,0 +1,32 @@
+shared_examples 'set sort order from user preference' do
+ describe '#set_sort_order_from_user_preference' do
+ # There is no issuable_sorting_field defined in any CE controllers yet,
+ # however any other field present in user_preferences table can be used for testing.
+ let(:sorting_field) { :issue_notes_filter }
+ let(:sorting_param) { 'any' }
+
+ before do
+ allow(controller).to receive(:issuable_sorting_field).and_return(sorting_field)
+ end
+
+ context 'when database is in read-only mode' do
+ it 'it does not update user preference' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+
+ expect_any_instance_of(UserPreference).not_to receive(:update_attribute).with(sorting_field, sorting_param)
+
+ get :index, namespace_id: project.namespace, project_id: project, sort: sorting_param
+ end
+ end
+
+ context 'when database is not in read-only mode' do
+ it 'updates user preference' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(false)
+
+ expect_any_instance_of(UserPreference).to receive(:update_attribute).with(sorting_field, sorting_param)
+
+ get :index, namespace_id: project.namespace, project_id: project, sort: sorting_param
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/namespace_file_uploader_spec.rb b/spec/uploaders/namespace_file_uploader_spec.rb
index d09725ee4be..77401814194 100644
--- a/spec/uploaders/namespace_file_uploader_spec.rb
+++ b/spec/uploaders/namespace_file_uploader_spec.rb
@@ -1,18 +1,22 @@
require 'spec_helper'
-IDENTIFIER = %r{\h+/\S+}
-
describe NamespaceFileUploader do
let(:group) { build_stubbed(:group) }
let(:uploader) { described_class.new(group) }
let(:upload) { create(:upload, :namespace_upload, model: group) }
+ let(:identifier) { %r{\h+/\S+} }
subject { uploader }
- it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/namespace/\d+],
- upload_path: IDENTIFIER,
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{IDENTIFIER}]
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[uploads/-/system/namespace/\d+],
+ upload_path: identifier,
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{identifier}]
+ }
+ end
+ end
context "object_store is REMOTE" do
before do
@@ -21,9 +25,14 @@ describe NamespaceFileUploader do
include_context 'with storage', described_class::Store::REMOTE
- it_behaves_like 'builds correct paths',
- store_dir: %r[namespace/\d+/\h+],
- upload_path: IDENTIFIER
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[namespace/\d+/\h+],
+ upload_path: identifier
+ }
+ end
+ end
end
context '.base_dir' do
diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb
index 7700b14ce6b..2896e9a112d 100644
--- a/spec/uploaders/personal_file_uploader_spec.rb
+++ b/spec/uploaders/personal_file_uploader_spec.rb
@@ -1,18 +1,22 @@
require 'spec_helper'
-IDENTIFIER = %r{\h+/\S+}
-
describe PersonalFileUploader do
let(:model) { create(:personal_snippet) }
let(:uploader) { described_class.new(model) }
let(:upload) { create(:upload, :personal_snippet_upload) }
+ let(:identifier) { %r{\h+/\S+} }
subject { uploader }
- it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/personal_snippet/\d+],
- upload_path: IDENTIFIER,
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{IDENTIFIER}]
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[uploads/-/system/personal_snippet/\d+],
+ upload_path: identifier,
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{identifier}]
+ }
+ end
+ end
context "object_store is REMOTE" do
before do
@@ -21,9 +25,14 @@ describe PersonalFileUploader do
include_context 'with storage', described_class::Store::REMOTE
- it_behaves_like 'builds correct paths',
- store_dir: %r[\d+/\h+],
- upload_path: IDENTIFIER
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[\d+/\h+],
+ upload_path: identifier
+ }
+ end
+ end
end
describe '#to_h' do
diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb
index 082d09d3f16..f3f3386382f 100644
--- a/spec/validators/url_validator_spec.rb
+++ b/spec/validators/url_validator_spec.rb
@@ -143,4 +143,33 @@ describe UrlValidator do
end
end
end
+
+ context 'when ascii_only is' do
+ let(:url) { 'https://𝕘itⅼαƄ.com/foo/foo.bar'}
+ let(:validator) { described_class.new(attributes: [:link_url], ascii_only: ascii_only) }
+
+ context 'true' do
+ let(:ascii_only) { true }
+
+ it 'prevents unicode characters' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors.empty?).to be false
+ end
+ end
+
+ context 'false (default)' do
+ let(:ascii_only) { false }
+
+ it 'does not prevent unicode characters' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors.empty?).to be true
+ end
+ end
+ end
end
diff --git a/yarn.lock b/yarn.lock
index d7d2b89a881..041a974b193 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -616,6 +616,13 @@
lodash "^4.17.10"
to-fast-properties "^2.0.0"
+"@gitlab/csslab@^1.8.0":
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/csslab/-/csslab-1.8.0.tgz#54a2457fdc80f006665f0e578a5532780954ccfa"
+ integrity sha512-RZylRElufH1kwsBQlIDaVcrcXMyD5IEGrU6ABUd8W3LG8/F9jJ4Y3Ys7EPTpK/qFJyx86AutTtFGRxRNlMx85w==
+ dependencies:
+ bootstrap "4.1.3"
+
"@gitlab/eslint-config@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/eslint-config/-/eslint-config-1.2.0.tgz#115568a70edabbc024f1bc13ba1ba499a9ba05a9"
@@ -629,15 +636,15 @@
eslint-plugin-promise "^4.0.1"
eslint-plugin-vue "^5.0.0-beta.3"
-"@gitlab/svgs@^1.40.0":
- version "1.41.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.41.0.tgz#f80e3a0e259f3550af00685556ea925e471276d3"
- integrity sha512-tKUXyqe54efWBsjQBUcvNF0AvqmE2NI2No3Bnix/gKDRImzIlcgIkM67Y8zoJv1D0w4CO87WcaG5GLpIFIT1Pg==
+"@gitlab/svgs@^1.42.0":
+ version "1.42.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.42.0.tgz#54eb88606bb79b74373a3aa49d8c10557fb1fd7a"
+ integrity sha512-mVm1kyV/M1fTbQcW8Edbk7BPT2syQf+ot9qwFzLFiFXAn3jXTi6xy+DS+0cgoTnglSUsXVl4qcVAQjt8YoOOOQ==
-"@gitlab/ui@^1.14.0":
- version "1.14.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.14.0.tgz#f0fd7c0e6c45a36ab3be18d00e2908a8cb405f90"
- integrity sha512-jkBTN8qO41A894kcLo6b/mfLIgL8YNn+ZzjgzEXaZ3PyeQ3mKBdrBoSYkzH556qviroBvk/+3yyZz96VUo08qQ==
+"@gitlab/ui@^1.15.0":
+ version "1.15.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.15.0.tgz#288e189cb99de354aeb4598f9ac8cced5f47e139"
+ integrity sha512-Aiv/WABr8lBVJk0eoanSoO07Lr5Nnvuq82IjDnNzcw9enB1DAKvlstC2r9iiMfg1pVgV/uLdDeRFqH9eI1X4Rg==
dependencies:
babel-standalone "^6.26.0"
bootstrap-vue "^2.0.0-rc.11"