summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_canceled.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_created.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_failed.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_manual.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_not_found.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_pending.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_running.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_skipped.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_success.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_warning.icobin0 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_canceled.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_created.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_failed.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_manual.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_not_found.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_pending.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_running.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_skipped.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_success.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_warning.icobin5430 -> 4286 bytes
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js1
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js3
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js11
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js193
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue54
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue102
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue80
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue52
-rw-r--r--app/assets/javascripts/deploy_keys/eventhub.js3
-rw-r--r--app/assets/javascripts/deploy_keys/index.js21
-rw-r--r--app/assets/javascripts/deploy_keys/service/index.js34
-rw-r--r--app/assets/javascripts/deploy_keys/store/index.js9
-rw-r--r--app/assets/javascripts/dispatcher.js5
-rw-r--r--app/assets/javascripts/dropzone_input.js10
-rw-r--r--app/assets/javascripts/environments/components/environment.vue13
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue18
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue22
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue19
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue18
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue9
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js169
-rw-r--r--app/assets/javascripts/gl_form.js4
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js38
-rw-r--r--app/assets/javascripts/issue.js74
-rw-r--r--app/assets/javascripts/milestone.js76
-rw-r--r--app/assets/javascripts/monitoring/constants.js4
-rw-r--r--app/assets/javascripts/monitoring/deployments.js211
-rw-r--r--app/assets/javascripts/monitoring/prometheus_graph.js71
-rw-r--r--app/assets/javascripts/notes.js217
-rw-r--r--app/assets/javascripts/pipelines/components/stage.js115
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue173
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js11
-rw-r--r--app/assets/javascripts/users_select.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js13
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss3
-rw-r--r--app/assets/stylesheets/pages/diff.scss6
-rw-r--r--app/assets/stylesheets/pages/environments.scss29
-rw-r--r--app/assets/stylesheets/pages/issuable.scss11
-rw-r--r--app/assets/stylesheets/pages/issues.scss83
-rw-r--r--app/assets/stylesheets/pages/members.scss52
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss5
-rw-r--r--app/assets/stylesheets/pages/notes.scss2
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss50
-rw-r--r--app/assets/stylesheets/pages/wiki.scss1
-rw-r--r--app/controllers/admin/hooks_controller.rb26
-rw-r--r--app/controllers/concerns/milestone_actions.rb53
-rw-r--r--app/controllers/concerns/notes_actions.rb136
-rw-r--r--app/controllers/concerns/renders_notes.rb2
-rw-r--r--app/controllers/concerns/snippets_actions.rb4
-rw-r--r--app/controllers/concerns/toggle_award_emoji.rb3
-rw-r--r--app/controllers/concerns/uploads_actions.rb27
-rw-r--r--app/controllers/groups/milestones_controller.rb4
-rw-r--r--app/controllers/projects/application_controller.rb4
-rw-r--r--app/controllers/projects/artifacts_controller.rb36
-rw-r--r--app/controllers/projects/branches_controller.rb34
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb20
-rw-r--r--app/controllers/projects/deployments_controller.rb18
-rw-r--r--app/controllers/projects/hooks_controller.rb13
-rw-r--r--app/controllers/projects/issues_controller.rb21
-rwxr-xr-xapp/controllers/projects/merge_requests_controller.rb15
-rw-r--r--app/controllers/projects/milestones_controller.rb6
-rw-r--r--app/controllers/projects/notes_controller.rb139
-rw-r--r--app/controllers/projects/pages_controller.rb1
-rw-r--r--app/controllers/projects/pages_domains_controller.rb1
-rw-r--r--app/controllers/projects/pipelines_controller.rb10
-rw-r--r--app/controllers/projects/raw_controller.rb2
-rw-r--r--app/controllers/projects/uploads_controller.rb32
-rw-r--r--app/controllers/snippets/notes_controller.rb44
-rw-r--r--app/controllers/snippets_controller.rb20
-rw-r--r--app/controllers/uploads_controller.rb82
-rw-r--r--app/finders/notes_finder.rb2
-rw-r--r--app/finders/pipelines_finder.rb108
-rw-r--r--app/helpers/award_emoji_helper.rb8
-rw-r--r--app/helpers/blob_helper.rb30
-rw-r--r--app/helpers/gitlab_routing_helper.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb6
-rw-r--r--app/helpers/milestones_helper.rb24
-rw-r--r--app/helpers/notes_helper.rb24
-rw-r--r--app/helpers/snippets_helper.rb8
-rw-r--r--app/helpers/sorting_helper.rb8
-rw-r--r--app/helpers/tree_helper.rb4
-rw-r--r--app/models/blob.rb46
-rw-r--r--app/models/blob_viewer/base.rb11
-rw-r--r--app/models/ci/artifact_blob.rb35
-rw-r--r--app/models/commit.rb4
-rw-r--r--app/models/concerns/blob_like.rb48
-rw-r--r--app/models/concerns/cache_markdown_field.rb3
-rw-r--r--app/models/concerns/discussion_on_diff.rb1
-rw-r--r--app/models/concerns/note_on_diff.rb4
-rw-r--r--app/models/diff_discussion.rb20
-rw-r--r--app/models/diff_note.rb7
-rw-r--r--app/models/issue.rb8
-rw-r--r--app/models/legacy_diff_discussion.rb18
-rw-r--r--app/models/merge_request.rb21
-rw-r--r--app/models/namespace.rb6
-rw-r--r--app/models/note.rb22
-rw-r--r--app/models/project.rb30
-rw-r--r--app/models/project_services/chat_message/base_message.rb11
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb10
-rw-r--r--app/models/repository.rb50
-rw-r--r--app/models/snippet_blob.rb30
-rw-r--r--app/models/user.rb2
-rw-r--r--app/policies/personal_snippet_policy.rb6
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb11
-rw-r--r--app/serializers/README.md325
-rw-r--r--app/serializers/deploy_key_entity.rb14
-rw-r--r--app/serializers/deploy_key_serializer.rb3
-rw-r--r--app/serializers/deployment_entity.rb2
-rw-r--r--app/serializers/deployment_serializer.rb8
-rw-r--r--app/serializers/merge_request_create_entity.rb7
-rw-r--r--app/serializers/merge_request_create_serializer.rb3
-rw-r--r--app/serializers/project_entity.rb14
-rw-r--r--app/serializers/status_entity.rb5
-rw-r--r--app/services/merge_requests/build_service.rb2
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb54
-rw-r--r--app/services/projects/enable_deploy_key_service.rb5
-rw-r--r--app/services/projects/upload_service.rb22
-rw-r--r--app/services/todo_service.rb2
-rw-r--r--app/services/upload_service.rb20
-rw-r--r--app/uploaders/artifact_uploader.rb4
-rw-r--r--app/uploaders/file_uploader.rb10
-rw-r--r--app/uploaders/gitlab_uploader.rb4
-rw-r--r--app/uploaders/lfs_object_uploader.rb4
-rw-r--r--app/uploaders/personal_file_uploader.rb15
-rw-r--r--app/validators/dynamic_path_validator.rb215
-rw-r--r--app/validators/namespace_validator.rb73
-rw-r--r--app/validators/project_path_validator.rb35
-rw-r--r--app/views/admin/dashboard/index.html.haml6
-rw-r--r--app/views/admin/hooks/_form.html.haml40
-rw-r--r--app/views/admin/hooks/edit.html.haml14
-rw-r--r--app/views/admin/hooks/index.html.haml55
-rw-r--r--app/views/admin/users/show.html.haml6
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml2
-rw-r--r--app/views/discussions/_notes.html.haml2
-rw-r--r--app/views/layouts/nav/_project.html.haml2
-rw-r--r--app/views/layouts/project.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml6
-rw-r--r--app/views/projects/artifacts/_tree_directory.html.haml4
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml9
-rw-r--r--app/views/projects/artifacts/browse.html.haml24
-rw-r--r--app/views/projects/artifacts/file.html.haml33
-rw-r--r--app/views/projects/blob/_blob.html.haml16
-rw-r--r--app/views/projects/blob/_header.html.haml11
-rw-r--r--app/views/projects/blob/_header_content.html.haml10
-rw-r--r--app/views/projects/boards/components/sidebar/_assignee.html.haml3
-rw-r--r--app/views/projects/builds/_header.html.haml28
-rw-r--r--app/views/projects/builds/_sidebar.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml17
-rw-r--r--app/views/projects/commit/show.html.haml4
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml23
-rw-r--r--app/views/projects/diffs/_line.html.haml2
-rw-r--r--app/views/projects/empty.html.haml2
-rw-r--r--app/views/projects/environments/metrics.html.haml2
-rw-r--r--app/views/projects/hooks/_index.html.haml24
-rw-r--r--app/views/projects/hooks/edit.html.haml14
-rw-r--r--app/views/projects/issues/_new_branch.html.haml36
-rw-r--r--app/views/projects/issues/show.html.haml7
-rw-r--r--app/views/projects/merge_requests/_new_compare.html.haml2
-rw-r--r--app/views/projects/merge_requests/show/_versions.html.haml14
-rw-r--r--app/views/projects/notes/_actions.html.haml44
-rw-r--r--app/views/projects/notes/_edit.html.haml3
-rw-r--r--app/views/projects/notes/_edit_form.html.haml2
-rw-r--r--app/views/projects/notes/_note.html.haml101
-rw-r--r--app/views/projects/notes/_notes_with_form.html.haml2
-rw-r--r--app/views/projects/pages/_disabled.html.haml4
-rw-r--r--app/views/projects/pages/show.html.haml15
-rw-r--r--app/views/projects/pipelines/_head.html.haml4
-rw-r--r--app/views/projects/project_members/_index.html.haml6
-rw-r--r--app/views/projects/project_members/_team.html.haml10
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/settings/_head.html.haml11
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml1
-rw-r--r--app/views/projects/settings/repository/show.html.haml4
-rw-r--r--app/views/projects/tags/index.html.haml17
-rw-r--r--app/views/projects/tree/_tree_header.html.haml7
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml10
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml10
-rw-r--r--app/views/shared/milestones/_tab_loading.html.haml2
-rw-r--r--app/views/shared/milestones/_tabs.html.haml28
-rw-r--r--app/views/shared/notes/_note.html.haml66
-rw-r--r--app/views/shared/notes/_notes.html.haml (renamed from app/views/projects/notes/_notes.html.haml)4
-rw-r--r--app/views/shared/snippets/_blob.html.haml14
-rw-r--r--app/views/shared/web_hooks/_form.html.haml182
-rw-r--r--app/views/snippets/notes/_actions.html.haml13
-rw-r--r--app/views/snippets/notes/_edit.html.haml0
-rw-r--r--app/views/snippets/notes/_notes.html.haml2
-rw-r--r--app/views/snippets/show.html.haml5
-rw-r--r--app/views/users/_deletion_guidance.html.haml10
213 files changed, 3900 insertions, 1471 deletions
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico
new file mode 100644
index 00000000000..4af3582b60d
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_created.ico b/app/assets/images/ci_favicons/dev/favicon_status_created.ico
new file mode 100644
index 00000000000..13639da2e8a
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_created.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_failed.ico b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico
new file mode 100644
index 00000000000..5f0e711b104
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_manual.ico b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico
new file mode 100644
index 00000000000..8b1168a1267
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico
new file mode 100644
index 00000000000..ed19b69e1c5
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_pending.ico b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico
new file mode 100644
index 00000000000..5dfefd4cc5a
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_running.ico b/app/assets/images/ci_favicons/dev/favicon_status_running.ico
new file mode 100644
index 00000000000..a41539c0e3e
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_running.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico
new file mode 100644
index 00000000000..2c1ae552b93
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_success.ico b/app/assets/images/ci_favicons/dev/favicon_status_success.ico
new file mode 100644
index 00000000000..70f0ca61eca
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_success.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_warning.ico b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico
new file mode 100644
index 00000000000..db289e03eb1
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_canceled.ico b/app/assets/images/ci_favicons/favicon_status_canceled.ico
index 5a19458f2a2..23adcffff50 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_canceled.ico
+++ b/app/assets/images/ci_favicons/favicon_status_canceled.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_created.ico b/app/assets/images/ci_favicons/favicon_status_created.ico
index 4dca9640cb3..f9d93b390d8 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_created.ico
+++ b/app/assets/images/ci_favicons/favicon_status_created.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_failed.ico b/app/assets/images/ci_favicons/favicon_status_failed.ico
index c961ff9a69b..28a22ebf724 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_failed.ico
+++ b/app/assets/images/ci_favicons/favicon_status_failed.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_manual.ico b/app/assets/images/ci_favicons/favicon_status_manual.ico
index 5fbbc99ea7c..dbbf1abf30c 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_manual.ico
+++ b/app/assets/images/ci_favicons/favicon_status_manual.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_not_found.ico b/app/assets/images/ci_favicons/favicon_status_not_found.ico
index 21afa9c72e6..49b9b232dd1 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_not_found.ico
+++ b/app/assets/images/ci_favicons/favicon_status_not_found.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_pending.ico b/app/assets/images/ci_favicons/favicon_status_pending.ico
index 8be32dab85a..05962f3f148 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_pending.ico
+++ b/app/assets/images/ci_favicons/favicon_status_pending.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_running.ico b/app/assets/images/ci_favicons/favicon_status_running.ico
index f328ff1a5ed..7fa3d4d48d4 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_running.ico
+++ b/app/assets/images/ci_favicons/favicon_status_running.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_skipped.ico b/app/assets/images/ci_favicons/favicon_status_skipped.ico
index b4394e1b4af..b0c26b62068 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_skipped.ico
+++ b/app/assets/images/ci_favicons/favicon_status_skipped.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_success.ico b/app/assets/images/ci_favicons/favicon_status_success.ico
index 4f436c95242..b150960b5be 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_success.ico
+++ b/app/assets/images/ci_favicons/favicon_status_success.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_warning.ico b/app/assets/images/ci_favicons/favicon_status_warning.ico
index 805cc20cdec..7e71d71684d 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_warning.ico
+++ b/app/assets/images/ci_favicons/favicon_status_warning.ico
Binary files differ
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 19a607309e4..23d91fdb259 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -62,6 +62,7 @@ function glEmojiTag(inputName, options) {
data-fallback-src="${fallbackImageSrc}"
${fallbackSpriteAttribute}
data-unicode-version="${emojiInfo.unicodeVersion}"
+ title="${emojiInfo.description}"
>
${contents}
</gl-emoji>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 004bac09f59..f0066d4ec5d 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -27,6 +27,9 @@ gl.issueBoards.BoardSidebar = Vue.extend({
computed: {
showSidebar () {
return Object.keys(this.issue).length;
+ },
+ assigneeId() {
+ return this.issue.assignee ? this.issue.assignee.id : 0;
}
},
watch: {
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
index e704be8b53e..ad9c600b499 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js
@@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', {
isLoading: false,
hasError: false,
isMakingRequest: false,
+ updateGraphDropdown: false,
};
},
@@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', {
const pipelines = response.pipelines || response;
this.store.storePipelines(pipelines);
this.isLoading = false;
+ this.updateGraphDropdown = true;
},
errorCallback() {
this.hasError = true;
this.isLoading = false;
+ this.updateGraphDropdown = false;
},
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
+
+ if (isMakingRequest) {
+ this.updateGraphDropdown = false;
+ }
},
},
@@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', {
v-if="shouldRenderTable">
<pipelines-table-component
:pipelines="state.pipelines"
- :service="service" />
+ :service="service"
+ :update-graph-dropdown="updateGraphDropdown"
+ />
</div>
</div>
`,
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
new file mode 100644
index 00000000000..ff2f2c81971
--- /dev/null
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -0,0 +1,193 @@
+/* eslint-disable no-new */
+/* global Flash */
+import DropLab from './droplab/drop_lab';
+import ISetter from './droplab/plugins/input_setter';
+
+// Todo: Remove this when fixing issue in input_setter plugin
+const InputSetter = Object.assign({}, ISetter);
+
+const CREATE_MERGE_REQUEST = 'create-mr';
+const CREATE_BRANCH = 'create-branch';
+
+export default class CreateMergeRequestDropdown {
+ constructor(wrapperEl) {
+ this.wrapperEl = wrapperEl;
+ this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
+ this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
+ this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
+ this.availableButton = this.wrapperEl.querySelector('.available');
+ this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
+ this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
+ this.unavailableButtonText = this.unavailableButton.querySelector('.text');
+
+ this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
+ this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
+ this.createMrPath = this.wrapperEl.dataset.createMrPath;
+ this.droplabInitialized = false;
+ this.isCreatingMergeRequest = false;
+ this.mergeRequestCreated = false;
+ this.isCreatingBranch = false;
+ this.branchCreated = false;
+
+ this.init();
+ }
+
+ init() {
+ this.checkAbilityToCreateBranch();
+ }
+
+ available() {
+ this.availableButton.classList.remove('hide');
+ this.unavailableButton.classList.add('hide');
+ }
+
+ unavailable() {
+ this.availableButton.classList.add('hide');
+ this.unavailableButton.classList.remove('hide');
+ }
+
+ enable() {
+ this.createMergeRequestButton.classList.remove('disabled');
+ this.createMergeRequestButton.removeAttribute('disabled');
+
+ this.dropdownToggle.classList.remove('disabled');
+ this.dropdownToggle.removeAttribute('disabled');
+ }
+
+ disable() {
+ this.createMergeRequestButton.classList.add('disabled');
+ this.createMergeRequestButton.setAttribute('disabled', 'disabled');
+
+ this.dropdownToggle.classList.add('disabled');
+ this.dropdownToggle.setAttribute('disabled', 'disabled');
+ }
+
+ hide() {
+ this.wrapperEl.classList.add('hide');
+ }
+
+ setUnavailableButtonState(isLoading = true) {
+ if (isLoading) {
+ this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin');
+ this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = 'Checking branch availability…';
+ } else {
+ this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin');
+ this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = 'New branch unavailable';
+ }
+ }
+
+ checkAbilityToCreateBranch() {
+ return $.ajax({
+ type: 'GET',
+ dataType: 'json',
+ url: this.canCreatePath,
+ beforeSend: () => this.setUnavailableButtonState(),
+ })
+ .done((data) => {
+ this.setUnavailableButtonState(false);
+
+ if (data.can_create_branch) {
+ this.available();
+ this.enable();
+
+ if (!this.droplabInitialized) {
+ this.droplabInitialized = true;
+ this.initDroplab();
+ this.bindEvents();
+ }
+ } else if (data.has_related_branch) {
+ this.hide();
+ }
+ }).fail(() => {
+ this.unavailable();
+ this.disable();
+ new Flash('Failed to check if a new branch can be created.');
+ });
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+
+ this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter],
+ this.getDroplabConfig());
+ }
+
+ getDroplabConfig() {
+ return {
+ InputSetter: [{
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ }, {
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-text',
+ }],
+ };
+ }
+
+ bindEvents() {
+ this.createMergeRequestButton
+ .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ }
+
+ isBusy() {
+ return this.isCreatingMergeRequest ||
+ this.mergeRequestCreated ||
+ this.isCreatingBranch ||
+ this.branchCreated;
+ }
+
+ onClickCreateMergeRequestButton(e) {
+ let xhr = null;
+ e.preventDefault();
+
+ if (this.isBusy()) {
+ return;
+ }
+
+ if (e.target.dataset.action === CREATE_MERGE_REQUEST) {
+ xhr = this.createMergeRequest();
+ } else if (e.target.dataset.action === CREATE_BRANCH) {
+ xhr = this.createBranch();
+ }
+
+ xhr.fail(() => {
+ this.isCreatingMergeRequest = false;
+ this.isCreatingBranch = false;
+ });
+
+ xhr.always(() => this.enable());
+
+ this.disable();
+ }
+
+ createMergeRequest() {
+ return $.ajax({
+ method: 'POST',
+ dataType: 'json',
+ url: this.createMrPath,
+ beforeSend: () => (this.isCreatingMergeRequest = true),
+ })
+ .done((data) => {
+ this.mergeRequestCreated = true;
+ window.location.href = data.url;
+ })
+ .fail(() => new Flash('Failed to create Merge Request. Please try again.'));
+ }
+
+ createBranch() {
+ return $.ajax({
+ method: 'POST',
+ dataType: 'json',
+ url: this.createBranchPath,
+ beforeSend: () => (this.isCreatingBranch = true),
+ })
+ .done((data) => {
+ this.branchCreated = true;
+ window.location.href = data.url;
+ })
+ .fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
+ }
+}
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
new file mode 100644
index 00000000000..3ff3a9d977e
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -0,0 +1,54 @@
+<script>
+ import eventHub from '../eventhub';
+
+ export default {
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ props: {
+ deployKey: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ btnCssClass: {
+ type: String,
+ required: false,
+ default: 'btn-default',
+ },
+ },
+ methods: {
+ doAction() {
+ this.isLoading = true;
+
+ eventHub.$emit(`${this.type}.key`, this.deployKey);
+ },
+ },
+ computed: {
+ text() {
+ return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ class="btn btn-sm prepend-left-10"
+ :class="[{ disabled: isLoading }, btnCssClass]"
+ :disabled="isLoading"
+ @click="doAction">
+ {{ text }}
+ <i
+ v-if="isLoading"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ aria-label="Loading">
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
new file mode 100644
index 00000000000..7315a9e11cb
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -0,0 +1,102 @@
+<script>
+ /* global Flash */
+ import eventHub from '../eventhub';
+ import DeployKeysService from '../service';
+ import DeployKeysStore from '../store';
+ import keysPanel from './keys_panel.vue';
+
+ export default {
+ data() {
+ return {
+ isLoading: false,
+ store: new DeployKeysStore(),
+ };
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ hasKeys() {
+ return Object.keys(this.keys).length;
+ },
+ keys() {
+ return this.store.keys;
+ },
+ },
+ components: {
+ keysPanel,
+ },
+ methods: {
+ fetchKeys() {
+ this.isLoading = true;
+
+ this.service.getKeys()
+ .then((data) => {
+ this.isLoading = false;
+ this.store.keys = data;
+ })
+ .catch(() => new Flash('Error getting deploy keys'));
+ },
+ enableKey(deployKey) {
+ this.service.enableKey(deployKey.id)
+ .then(() => this.fetchKeys())
+ .catch(() => new Flash('Error enabling deploy key'));
+ },
+ disableKey(deployKey) {
+ // eslint-disable-next-line no-alert
+ if (confirm('You are going to remove this deploy key. Are you sure?')) {
+ this.service.disableKey(deployKey.id)
+ .then(() => this.fetchKeys())
+ .catch(() => new Flash('Error removing deploy key'));
+ }
+ },
+ },
+ created() {
+ this.service = new DeployKeysService(this.endpoint);
+
+ eventHub.$on('enable.key', this.enableKey);
+ eventHub.$on('remove.key', this.disableKey);
+ eventHub.$on('disable.key', this.disableKey);
+ },
+ mounted() {
+ this.fetchKeys();
+ },
+ beforeDestroy() {
+ eventHub.$off('enable.key', this.enableKey);
+ eventHub.$off('remove.key', this.disableKey);
+ eventHub.$off('disable.key', this.disableKey);
+ },
+ };
+</script>
+
+<template>
+ <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys">
+ <div
+ class="text-center"
+ v-if="isLoading && !hasKeys">
+ <i
+ class="fa fa-spinner fa-spin fa-2x"
+ aria-hidden="true"
+ aria-label="Loading deploy keys">
+ </i>
+ </div>
+ <div v-else-if="hasKeys">
+ <keys-panel
+ title="Enabled deploy keys for this project"
+ :keys="keys.enabled_keys"
+ :store="store" />
+ <keys-panel
+ title="Deploy keys from projects you have access to"
+ :keys="keys.available_project_keys"
+ :store="store" />
+ <keys-panel
+ v-if="keys.public_keys.length"
+ title="Public deploy keys available to any project"
+ :keys="keys.public_keys"
+ :store="store" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
new file mode 100644
index 00000000000..0a06a481b96
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -0,0 +1,80 @@
+<script>
+ import actionBtn from './action_btn.vue';
+
+ export default {
+ props: {
+ deployKey: {
+ type: Object,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ actionBtn,
+ },
+ computed: {
+ timeagoDate() {
+ return gl.utils.getTimeago().format(this.deployKey.created_at);
+ },
+ },
+ methods: {
+ isEnabled(id) {
+ return this.store.findEnabledKey(id) !== undefined;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="pull-left append-right-10 hidden-xs">
+ <i
+ aria-hidden="true"
+ class="fa fa-key key-icon">
+ </i>
+ </div>
+ <div class="deploy-key-content key-list-item-info">
+ <strong class="title">
+ {{ deployKey.title }}
+ </strong>
+ <div class="description">
+ {{ deployKey.fingerprint }}
+ </div>
+ <div
+ v-if="deployKey.can_push"
+ class="write-access-allowed">
+ Write access allowed
+ </div>
+ </div>
+ <div class="deploy-key-content prepend-left-default deploy-key-projects">
+ <a
+ v-for="project in deployKey.projects"
+ class="label deploy-project-label"
+ :href="project.full_path">
+ {{ project.full_name }}
+ </a>
+ </div>
+ <div class="deploy-key-content">
+ <span class="key-created-at">
+ created {{ timeagoDate }}
+ </span>
+ <action-btn
+ v-if="!isEnabled(deployKey.id)"
+ :deploy-key="deployKey"
+ type="enable"/>
+ <action-btn
+ v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
+ :deploy-key="deployKey"
+ btn-css-class="btn-warning"
+ type="remove" />
+ <action-btn
+ v-else
+ :deploy-key="deployKey"
+ btn-css-class="btn-warning"
+ type="disable" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
new file mode 100644
index 00000000000..eccc470578b
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -0,0 +1,52 @@
+<script>
+ import key from './key.vue';
+
+ export default {
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ keys: {
+ type: Array,
+ required: true,
+ },
+ showHelpBox: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ key,
+ },
+ };
+</script>
+
+<template>
+ <div class="deploy-keys-panel">
+ <h5>
+ {{ title }}
+ ({{ keys.length }})
+ </h5>
+ <ul class="well-list"
+ v-if="keys.length">
+ <li
+ v-for="deployKey in keys"
+ :key="deployKey.id">
+ <key
+ :deploy-key="deployKey"
+ :store="store" />
+ </li>
+ </ul>
+ <div
+ class="settings-message text-center"
+ v-else-if="showHelpBox">
+ No deploy keys found. Create one with the form above.
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
new file mode 100644
index 00000000000..a5f232f950a
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import deployKeysApp from './components/app.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: document.getElementById('js-deploy-keys'),
+ data() {
+ return {
+ endpoint: this.$options.el.dataset.endpoint,
+ };
+ },
+ components: {
+ deployKeysApp,
+ },
+ render(createElement) {
+ return createElement('deploy-keys-app', {
+ props: {
+ endpoint: this.endpoint,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js
new file mode 100644
index 00000000000..fe6dbaa9498
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/service/index.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class DeployKeysService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+
+ this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, {
+ enable: {
+ method: 'PUT',
+ url: `${this.endpoint}{/id}/enable`,
+ },
+ disable: {
+ method: 'PUT',
+ url: `${this.endpoint}{/id}/disable`,
+ },
+ });
+ }
+
+ getKeys() {
+ return this.resource.get()
+ .then(response => response.json());
+ }
+
+ enableKey(id) {
+ return this.resource.enable({ id }, {});
+ }
+
+ disableKey(id) {
+ return this.resource.disable({ id }, {});
+ }
+}
diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js
new file mode 100644
index 00000000000..6210361af26
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/store/index.js
@@ -0,0 +1,9 @@
+export default class DeployKeysStore {
+ constructor() {
+ this.keys = {};
+ }
+
+ findEnabledKey(id) {
+ return this.keys.enabled_keys.find(key => key.id === id);
+ }
+}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 0bdce52cc89..b87c57c38fe 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -50,6 +50,7 @@ import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki';
import BlobViewer from './blob/viewer/index';
+import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
const ShortcutsBlob = require('./shortcuts_blob');
@@ -198,6 +199,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new LabelsSelect();
new MilestoneSelect();
new gl.IssuableTemplateSelectors();
+ new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
break;
case 'projects:tags:new':
new ZenMode();
@@ -344,6 +346,9 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:artifacts:browse':
new BuildArtifacts();
break;
+ case 'projects:artifacts:file':
+ new BlobViewer();
+ break;
case 'help:index':
gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
break;
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index b70d242269d..b3a76fbb43e 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -5,7 +5,7 @@ require('./preview_markdown');
window.DropzoneInput = (function() {
function DropzoneInput(form) {
- var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress;
+ var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress;
Dropzone.autoDiscover = false;
alertClass = "alert alert-danger alert-dismissable div-dropzone-alert";
alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"";
@@ -16,7 +16,7 @@ window.DropzoneInput = (function() {
iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>";
uploadProgress = $("<div class=\"div-dropzone-progress\"></div>");
btnAlert = "<button type=\"button\"" + alertAttr + ">&times;</button>";
- project_uploads_path = window.project_uploads_path || null;
+ uploads_path = window.uploads_path || null;
max_file_size = gon.max_file_size || 10;
form_textarea = $(form).find(".js-gfm-input");
form_textarea.wrap("<div class=\"div-dropzone\"></div>");
@@ -39,10 +39,10 @@ window.DropzoneInput = (function() {
"display": "none"
});
- if (!project_uploads_path) return;
+ if (!uploads_path) return;
dropzone = form_dropzone.dropzone({
- url: project_uploads_path,
+ url: uploads_path,
dictDefaultMessage: "",
clickable: true,
paramName: "file",
@@ -159,7 +159,7 @@ window.DropzoneInput = (function() {
formData = new FormData();
formData.append("file", item, filename);
return $.ajax({
- url: project_uploads_path,
+ url: uploads_path,
type: "POST",
data: formData,
dataType: "json",
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index f319d6ca0c8..e0088d496eb 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -1,6 +1,4 @@
<script>
-
-/* eslint-disable no-new */
/* global Flash */
import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from './environments_table.vue';
@@ -71,11 +69,13 @@ export default {
eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
+ eventHub.$on('postAction', this.postAction);
},
beforeDestroyed() {
eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder');
+ eventHub.$off('postAction');
},
methods: {
@@ -122,6 +122,7 @@ export default {
})
.catch(() => {
this.isLoading = false;
+ // eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
});
},
@@ -137,9 +138,16 @@ export default {
})
.catch(() => {
this.isLoadingFolderContent = false;
+ // eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
});
},
+
+ postAction(endpoint) {
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ },
},
};
</script>
@@ -217,7 +225,6 @@ export default {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
- :service="service"
:is-loading-folder-content="isLoadingFolderContent" />
</div>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index e81c97260d7..63bffe8a998 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,7 +1,4 @@
<script>
-/* global Flash */
-/* eslint-disable no-new */
-
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
@@ -12,11 +9,6 @@ export default {
required: false,
default: () => [],
},
-
- service: {
- type: Object,
- required: true,
- },
},
data() {
@@ -38,15 +30,7 @@ export default {
$(this.$refs.tooltip).tooltip('destroy');
- this.service.postAction(endpoint)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshEnvironments');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
+ eventHub.$emit('postAction', endpoint);
},
isActionDisabled(action) {
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 73679de6039..0ffe9ea17fa 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -46,11 +46,6 @@ export default {
required: false,
default: false,
},
-
- service: {
- type: Object,
- required: true,
- },
},
computed: {
@@ -543,31 +538,34 @@ export default {
<actions-component
v-if="hasManualActions && canCreateDeployment"
- :service="service"
- :actions="manualActions"/>
+ :actions="manualActions"
+ />
<external-url-component
v-if="externalURL && canReadEnvironment"
- :external-url="externalURL"/>
+ :external-url="externalURL"
+ />
<monitoring-button-component
v-if="monitoringUrl && canReadEnvironment"
- :monitoring-url="monitoringUrl"/>
+ :monitoring-url="monitoringUrl"
+ />
<terminal-button-component
v-if="model && model.terminal_path"
- :terminal-path="model.terminal_path"/>
+ :terminal-path="model.terminal_path"
+ />
<stop-component
v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path"
- :service="service"/>
+ />
<rollback-component
v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
- :service="service"/>
+ />
</div>
</td>
</tr>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index f139f24036f..44b8730fd09 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -1,6 +1,4 @@
<script>
-/* global Flash */
-/* eslint-disable no-new */
/**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
@@ -20,11 +18,6 @@ export default {
type: Boolean,
default: true,
},
-
- service: {
- type: Object,
- required: true,
- },
},
data() {
@@ -37,17 +30,7 @@ export default {
onClick() {
this.isLoading = true;
- $(this.$el).tooltip('destroy');
-
- this.service.postAction(this.retryUrl)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshEnvironments');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
+ eventHub.$emit('postAction', this.retryUrl);
},
},
};
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index 11e9aff7b92..f483ea7e937 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -1,6 +1,4 @@
<script>
-/* global Flash */
-/* eslint-disable no-new, no-alert */
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
@@ -13,11 +11,6 @@ export default {
type: String,
default: '',
},
-
- service: {
- type: Object,
- required: true,
- },
},
data() {
@@ -34,20 +27,13 @@ export default {
methods: {
onClick() {
+ // eslint-disable-next-line no-alert
if (confirm('Are you sure you want to stop this environment?')) {
this.isLoading = true;
$(this.$el).tooltip('destroy');
- this.service.postAction(this.retryUrl)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshEnvironments');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.', 'alert');
- });
+ eventHub.$emit('postAction', this.stopUrl);
}
},
},
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 87f7cb4a536..15eedaf76e1 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -28,11 +28,6 @@ export default {
default: false,
},
- service: {
- type: Object,
- required: true,
- },
-
isLoadingFolderContent: {
type: Boolean,
required: false,
@@ -78,7 +73,7 @@ export default {
:model="model"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- :service="service" />
+ />
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
<tr v-if="isLoadingFolderContent">
@@ -96,7 +91,7 @@ export default {
:model="children"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- :service="service" />
+ />
<tr>
<td
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index d27b2acfcdf..f4a0c390c91 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable no-new */
/* global Flash */
import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from '../components/environments_table.vue';
@@ -99,6 +98,7 @@ export default {
})
.catch(() => {
this.isLoading = false;
+ // eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.', 'alert');
});
},
@@ -169,7 +169,7 @@ export default {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
- :service="service"/>
+ />
<table-pagination
v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 687a462a0d4..f1b99023c72 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = {
}
}
},
- setup: function(input) {
+ setup: function(input, enableMap = {
+ emojis: true,
+ members: true,
+ issues: true,
+ milestones: true,
+ mergeRequests: true,
+ labels: true
+ }) {
// Add GFM auto-completion to all input fields, that accept GFM input.
this.input = input || $('.js-gfm-input');
+ this.enableMap = enableMap;
this.setupLifecycle();
},
setupLifecycle() {
@@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = {
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
});
},
+
setupAtWho: function($input) {
+ if (this.enableMap.emojis) this.setupEmoji($input);
+ if (this.enableMap.members) this.setupMembers($input);
+ if (this.enableMap.issues) this.setupIssues($input);
+ if (this.enableMap.milestones) this.setupMilestones($input);
+ if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
+ if (this.enableMap.labels) this.setupLabels($input);
+
+ // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
+ $input.filter('[data-supports-slash-commands="true"]').atwho({
+ at: '/',
+ alias: 'commands',
+ searchKey: 'search',
+ skipSpecialCharacterTest: true,
+ data: this.defaultLoadingData,
+ displayTpl: function(value) {
+ if (this.isLoading(value)) return this.Loading.template;
+ var tpl = '<li>/${name}';
+ if (value.aliases.length > 0) {
+ tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
+ }
+ if (value.params.length > 0) {
+ tpl += ' <small><%- params.join(" ") %></small>';
+ }
+ if (value.description !== '') {
+ tpl += '<small class="description"><i><%- description %></i></small>';
+ }
+ tpl += '</li>';
+ return _.template(tpl)(value);
+ }.bind(this),
+ insertTpl: function(value) {
+ var tpl = "/${name} ";
+ var reference_prefix = null;
+ if (value.params.length > 0) {
+ reference_prefix = value.params[0][0];
+ if (/^[@%~]/.test(reference_prefix)) {
+ tpl += '<%- reference_prefix %>';
+ }
+ }
+ return _.template(tpl)({ reference_prefix: reference_prefix });
+ },
+ suffix: '',
+ callbacks: {
+ sorter: this.DefaultOptions.sorter,
+ filter: this.DefaultOptions.filter,
+ beforeInsert: this.DefaultOptions.beforeInsert,
+ beforeSave: function(commands) {
+ if (gl.GfmAutoComplete.isLoading(commands)) return commands;
+ return $.map(commands, function(c) {
+ var search = c.name;
+ if (c.aliases.length > 0) {
+ search = search + " " + c.aliases.join(" ");
+ }
+ return {
+ name: c.name,
+ aliases: c.aliases,
+ params: c.params,
+ description: c.description,
+ search: search
+ };
+ });
+ },
+ matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
+ var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
+ var match = regexp.exec(subtext);
+ if (match) {
+ return match[1];
+ } else {
+ return null;
+ }
+ }
+ }
+ });
+ return;
+ },
+
+ setupEmoji($input) {
// Emoji
$input.atwho({
at: ':',
@@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = {
}
}
});
+ },
+
+ setupMembers($input) {
// Team Members
$input.atwho({
at: '@',
@@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = {
}
}
});
+ },
+
+ setupIssues($input) {
$input.atwho({
at: '#',
alias: 'issues',
@@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = {
}
}
});
+ },
+
+ setupMilestones($input) {
$input.atwho({
at: '%',
alias: 'milestones',
@@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = {
}
}
});
+ },
+
+ setupMergeRequests($input) {
$input.atwho({
at: '!',
alias: 'mergerequests',
@@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = {
}
}
});
+ },
+
+ setupLabels($input) {
$input.atwho({
at: '~',
alias: 'labels',
@@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = {
}
}
});
- // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
- $input.filter('[data-supports-slash-commands="true"]').atwho({
- at: '/',
- alias: 'commands',
- searchKey: 'search',
- skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- if (this.isLoading(value)) return this.Loading.template;
- var tpl = '<li>/${name}';
- if (value.aliases.length > 0) {
- tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
- }
- if (value.params.length > 0) {
- tpl += ' <small><%- params.join(" ") %></small>';
- }
- if (value.description !== '') {
- tpl += '<small class="description"><i><%- description %></i></small>';
- }
- tpl += '</li>';
- return _.template(tpl)(value);
- }.bind(this),
- insertTpl: function(value) {
- var tpl = "/${name} ";
- var reference_prefix = null;
- if (value.params.length > 0) {
- reference_prefix = value.params[0][0];
- if (/^[@%~]/.test(reference_prefix)) {
- tpl += '<%- reference_prefix %>';
- }
- }
- return _.template(tpl)({ reference_prefix: reference_prefix });
- },
- suffix: '',
- callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- beforeSave: function(commands) {
- if (gl.GfmAutoComplete.isLoading(commands)) return commands;
- return $.map(commands, function(c) {
- var search = c.name;
- if (c.aliases.length > 0) {
- search = search + " " + c.aliases.join(" ");
- }
- return {
- name: c.name,
- aliases: c.aliases,
- params: c.params,
- description: c.description,
- search: search
- };
- });
- },
- matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
- var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
- var match = regexp.exec(subtext);
- if (match) {
- return match[1];
- } else {
- return null;
- }
- }
- }
- });
- return;
},
+
fetchData: function($input, at) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index ff10f19a4fe..ff06092e4d6 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -34,9 +34,9 @@ GLForm.prototype.setupForm = function() {
gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
new DropzoneInput(this.form);
autosize(this.textarea);
- // form and textarea event listeners
- this.addEventListeners();
}
+ // form and textarea event listeners
+ this.addEventListeners();
gl.text.init(this.form);
// hide discard button
this.form.find('.js-note-discard').hide();
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
new file mode 100644
index 00000000000..2203a56315e
--- /dev/null
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -0,0 +1,38 @@
+let instanceCount = 0;
+
+class AutoWidthDropdownSelect {
+ constructor(selectElement) {
+ this.$selectElement = $(selectElement);
+ this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`;
+ instanceCount += 1;
+ }
+
+ init() {
+ const dropdownClass = this.dropdownClass;
+ this.$selectElement.select2({
+ dropdownCssClass: dropdownClass,
+ dropdownCss() {
+ let resultantWidth = 'auto';
+ const $dropdown = $(`.${dropdownClass}`);
+
+ // We have to look at the parent because
+ // `offsetParent` on a `display: none;` is `null`
+ const offsetParentWidth = $(this).parent().offsetParent().width();
+ // Reset any width to let it naturally flow
+ $dropdown.css('width', 'auto');
+ if ($dropdown.outerWidth(false) > offsetParentWidth) {
+ resultantWidth = offsetParentWidth;
+ }
+
+ return {
+ width: resultantWidth,
+ maxWidth: offsetParentWidth,
+ };
+ },
+ });
+
+ return this;
+ }
+}
+
+export default AutoWidthDropdownSelect;
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 011043e992f..694c6177a07 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */
-/* global Flash */
+ /* global Flash */
+import CreateMergeRequestDropdown from './create_merge_request_dropdown';
require('./flash');
require('~/lib/utils/text_utility');
@@ -18,48 +19,49 @@ class Issue {
document.querySelector('#task_status_short').innerText = result.task_status_short;
}
});
- Issue.initIssueBtnEventListeners();
+ this.initIssueBtnEventListeners();
}
Issue.$btnNewBranch = $('#new-branch');
+ Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
Issue.initMergeRequests();
Issue.initRelatedBranches();
- Issue.initCanCreateBranch();
+
+ if (Issue.createMrDropdownWrap) {
+ this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
+ }
}
- static initIssueBtnEventListeners() {
+ initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.';
-
const closeButtons = $('a.btn-close');
const isClosedBadge = $('div.status-box-closed');
const isOpenBadge = $('div.status-box-open');
const projectIssuesCounter = $('.issue_counter');
const reopenButtons = $('a.btn-reopen');
- return closeButtons.add(reopenButtons).on('click', function(e) {
- var $this, shouldSubmit, url;
+ return closeButtons.add(reopenButtons).on('click', (e) => {
+ var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
- $this = $(this);
- shouldSubmit = $this.hasClass('btn-comment');
+ $button = $(e.currentTarget);
+ shouldSubmit = $button.hasClass('btn-comment');
if (shouldSubmit) {
- Issue.submitNoteForm($this.closest('form'));
+ Issue.submitNoteForm($button.closest('form'));
}
- $this.prop('disabled', true);
- Issue.setNewBranchButtonState(true, null);
- url = $this.attr('href');
+ $button.prop('disabled', true);
+ url = $button.attr('href');
return $.ajax({
type: 'PUT',
url: url
- }).fail(function(jqXHR, textStatus, errorThrown) {
- new Flash(issueFailMessage);
- Issue.initCanCreateBranch();
- }).done(function(data, textStatus, jqXHR) {
+ })
+ .fail(() => new Flash(issueFailMessage))
+ .done((data) => {
if ('id' in data) {
$(document).trigger('issuable:change');
- const isClosed = $this.hasClass('btn-close');
+ const isClosed = $button.hasClass('btn-close');
closeButtons.toggleClass('hidden', isClosed);
reopenButtons.toggleClass('hidden', !isClosed);
isClosedBadge.toggleClass('hidden', !isClosed);
@@ -68,12 +70,21 @@ class Issue {
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
+
+ if (this.createMergeRequestDropdown) {
+ if (isClosed) {
+ this.createMergeRequestDropdown.unavailable();
+ this.createMergeRequestDropdown.disable();
+ } else {
+ // We should check in case a branch was created in another tab
+ this.createMergeRequestDropdown.checkAbilityToCreateBranch();
+ }
+ }
} else {
new Flash(issueFailMessage);
}
- $this.prop('disabled', false);
- Issue.initCanCreateBranch();
+ $button.prop('disabled', false);
});
});
}
@@ -109,29 +120,6 @@ class Issue {
}
});
}
-
- static initCanCreateBranch() {
- // If the user doesn't have the required permissions the container isn't
- // rendered at all.
- if (Issue.$btnNewBranch.length === 0) {
- return;
- }
- return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() {
- Issue.setNewBranchButtonState(false, false);
- new Flash('Failed to check if a new branch can be created.');
- }).done(function(data) {
- Issue.setNewBranchButtonState(false, data.can_create_branch);
- });
- }
-
- static setNewBranchButtonState(isPending, canCreate) {
- if (Issue.$btnNewBranch.length === 0) {
- return;
- }
-
- Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate);
- Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate);
- }
}
export default Issue;
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index 38c673e8907..841b24a60a3 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -19,12 +19,10 @@
});
};
- Milestone.sortIssues = function(data) {
- var sort_issues_url;
- sort_issues_url = location.href + "/sort_issues";
+ Milestone.sortIssues = function(url, data) {
return $.ajax({
type: "PUT",
- url: sort_issues_url,
+ url,
data: data,
success: function(_data) {
return Milestone.successCallback(_data);
@@ -36,12 +34,10 @@
});
};
- Milestone.sortMergeRequests = function(data) {
- var sort_mr_url;
- sort_mr_url = location.href + "/sort_merge_requests";
+ Milestone.sortMergeRequests = function(url, data) {
return $.ajax({
type: "PUT",
- url: sort_mr_url,
+ url,
data: data,
success: function(_data) {
return Milestone.successCallback(_data);
@@ -81,42 +77,55 @@
};
function Milestone() {
- var oldMouseStart;
+ this.issuesSortEndpoint = $('#tab-issues').data('sort-endpoint');
+ this.mergeRequestsSortEndpoint = $('#tab-merge-requests').data('sort-endpoint');
+
this.bindIssuesSorting();
- this.bindMergeRequestSorting();
this.bindTabsSwitching();
+
+ // Load merge request tab if it is active
+ // merge request tab is active based on different conditions in the backend
+ this.loadTab($('.js-milestone-tabs .active a'));
+
+ this.loadInitialTab();
}
Milestone.prototype.bindIssuesSorting = function() {
+ if (!this.issuesSortEndpoint) return;
+
$('#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed').each(function (i, el) {
this.createSortable(el, {
group: 'issue-list',
listEls: $('.issues-sortable-list'),
fieldName: 'issue',
- sortCallback: Milestone.sortIssues,
+ sortCallback: (data) => {
+ Milestone.sortIssues(this.issuesSortEndpoint, data);
+ },
updateCallback: Milestone.updateIssue,
});
}.bind(this));
};
Milestone.prototype.bindTabsSwitching = function() {
- return $('a[data-toggle="tab"]').on('show.bs.tab', function(e) {
- var currentTabClass, previousTabClass;
- currentTabClass = $(e.target).data('show');
- previousTabClass = $(e.relatedTarget).data('show');
- $(previousTabClass).hide();
- $(currentTabClass).removeClass('hidden');
- return $(currentTabClass).show();
+ return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => {
+ const $target = $(e.target);
+
+ location.hash = $target.attr('href');
+ this.loadTab($target);
});
};
Milestone.prototype.bindMergeRequestSorting = function() {
+ if (!this.mergeRequestsSortEndpoint) return;
+
$("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").each(function (i, el) {
this.createSortable(el, {
group: 'merge-request-list',
listEls: $(".merge_requests-sortable-list:not(#merge_requests-list-merged)"),
fieldName: 'merge_request',
- sortCallback: Milestone.sortMergeRequests,
+ sortCallback: (data) => {
+ Milestone.sortMergeRequests(this.mergeRequestsSortEndpoint, data);
+ },
updateCallback: Milestone.updateMergeRequest,
});
}.bind(this));
@@ -169,6 +178,35 @@
});
};
+ Milestone.prototype.loadInitialTab = function() {
+ const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`);
+
+ if ($target.length) {
+ $target.tab('show');
+ }
+ };
+
+ Milestone.prototype.loadTab = function($target) {
+ const endpoint = $target.data('endpoint');
+ const tabElId = $target.attr('href');
+
+ if (endpoint && !$target.hasClass('is-loaded')) {
+ $.ajax({
+ url: endpoint,
+ dataType: 'JSON',
+ })
+ .fail(() => new Flash('Error loading milestone tab'))
+ .done((data) => {
+ $(tabElId).html(data.html);
+ $target.addClass('is-loaded');
+
+ if (tabElId === '#tab-merge-requests') {
+ this.bindMergeRequestSorting();
+ }
+ });
+ }
+ };
+
return Milestone;
})();
}).call(window);
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
new file mode 100644
index 00000000000..c3a8da52404
--- /dev/null
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -0,0 +1,4 @@
+import d3 from 'd3';
+
+export const dateFormat = d3.time.format('%b %d, %Y');
+export const timeFormat = d3.time.format('%H:%M%p');
diff --git a/app/assets/javascripts/monitoring/deployments.js b/app/assets/javascripts/monitoring/deployments.js
new file mode 100644
index 00000000000..fc92ab61b31
--- /dev/null
+++ b/app/assets/javascripts/monitoring/deployments.js
@@ -0,0 +1,211 @@
+/* global Flash */
+import d3 from 'd3';
+import {
+ dateFormat,
+ timeFormat,
+} from './constants';
+
+export default class Deployments {
+ constructor(width, height) {
+ this.width = width;
+ this.height = height;
+
+ this.endpoint = document.getElementById('js-metrics').dataset.deploymentEndpoint;
+
+ this.createGradientDef();
+ }
+
+ init(chartData) {
+ this.chartData = chartData;
+
+ this.x = d3.time.scale().range([0, this.width]);
+ this.x.domain(d3.extent(this.chartData, d => d.time));
+
+ this.charts = d3.selectAll('.prometheus-graph');
+
+ this.getData();
+ }
+
+ getData() {
+ $.ajax({
+ url: this.endpoint,
+ dataType: 'JSON',
+ })
+ .fail(() => new Flash('Error getting deployment information.'))
+ .done((data) => {
+ this.data = data.deployments.reduce((deploymentDataArray, deployment) => {
+ const time = new Date(deployment.created_at);
+ const xPos = Math.floor(this.x(time));
+
+ time.setSeconds(this.chartData[0].time.getSeconds());
+
+ if (xPos >= 0) {
+ deploymentDataArray.push({
+ id: deployment.id,
+ time,
+ sha: deployment.sha,
+ tag: deployment.tag,
+ ref: deployment.ref.name,
+ xPos,
+ });
+ }
+
+ return deploymentDataArray;
+ }, []);
+
+ this.plotData();
+ });
+ }
+
+ plotData() {
+ this.charts.each((d, i) => {
+ const svg = d3.select(this.charts[0][i]);
+ const chart = svg.select('.graph-container');
+ const key = svg.node().getAttribute('graph-type');
+
+ this.createLine(chart, key);
+ this.createDeployInfoBox(chart, key);
+ });
+ }
+
+ createGradientDef() {
+ const defs = d3.select('body')
+ .append('svg')
+ .attr({
+ height: 0,
+ width: 0,
+ })
+ .append('defs');
+
+ defs.append('linearGradient')
+ .attr({
+ id: 'shadow-gradient',
+ })
+ .append('stop')
+ .attr({
+ offset: '0%',
+ 'stop-color': '#000',
+ 'stop-opacity': 0.4,
+ })
+ .select(this.selectParentNode)
+ .append('stop')
+ .attr({
+ offset: '100%',
+ 'stop-color': '#000',
+ 'stop-opacity': 0,
+ });
+ }
+
+ createLine(chart, key) {
+ chart.append('g')
+ .attr({
+ class: 'deploy-info',
+ })
+ .selectAll('.deploy-info')
+ .data(this.data)
+ .enter()
+ .append('g')
+ .attr({
+ class: d => `deploy-info-${d.id}-${key}`,
+ transform: d => `translate(${Math.floor(d.xPos) + 1}, 0)`,
+ })
+ .append('rect')
+ .attr({
+ x: 1,
+ y: 0,
+ height: this.height + 1,
+ width: 3,
+ fill: 'url(#shadow-gradient)',
+ })
+ .select(this.selectParentNode)
+ .append('line')
+ .attr({
+ class: 'deployment-line',
+ x1: 0,
+ x2: 0,
+ y1: 0,
+ y2: this.height + 1,
+ });
+ }
+
+ createDeployInfoBox(chart, key) {
+ chart.selectAll('.deploy-info')
+ .selectAll('.js-deploy-info-box')
+ .data(this.data)
+ .enter()
+ .select(d => document.querySelector(`.deploy-info-${d.id}-${key}`))
+ .append('svg')
+ .attr({
+ class: 'js-deploy-info-box hidden',
+ x: 3,
+ y: 0,
+ width: 92,
+ height: 60,
+ })
+ .append('rect')
+ .attr({
+ class: 'rect-text-metric deploy-info-rect rect-metric',
+ x: 1,
+ y: 1,
+ rx: 2,
+ width: 90,
+ height: 58,
+ })
+ .select(this.selectParentNode)
+ .append('g')
+ .attr({
+ transform: 'translate(5, 2)',
+ })
+ .append('text')
+ .attr({
+ class: 'deploy-info-text text-metric-bold',
+ })
+ .text(Deployments.refText)
+ .select(this.selectParentNode)
+ .append('text')
+ .attr({
+ class: 'deploy-info-text',
+ y: 18,
+ })
+ .text(d => dateFormat(d.time))
+ .select(this.selectParentNode)
+ .append('text')
+ .attr({
+ class: 'deploy-info-text text-metric-bold',
+ y: 38,
+ })
+ .text(d => timeFormat(d.time));
+ }
+
+ static toggleDeployTextbox(deploy, key, showInfoBox) {
+ d3.selectAll(`.deploy-info-${deploy.id}-${key} .js-deploy-info-box`)
+ .classed('hidden', !showInfoBox);
+ }
+
+ mouseOverDeployInfo(mouseXPos, key) {
+ if (!this.data) return false;
+
+ let dataFound = false;
+
+ this.data.forEach((d) => {
+ if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
+ dataFound = d.xPos + 1;
+
+ Deployments.toggleDeployTextbox(d, key, true);
+ } else {
+ Deployments.toggleDeployTextbox(d, key, false);
+ }
+ });
+
+ return dataFound;
+ }
+
+ /* `this` is bound to the D3 node */
+ selectParentNode() {
+ return this.parentNode;
+ }
+
+ static refText(d) {
+ return d.tag ? d.ref : d.sha.slice(0, 6);
+ }
+}
diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js
index 78bb0e6fb47..6af88769129 100644
--- a/app/assets/javascripts/monitoring/prometheus_graph.js
+++ b/app/assets/javascripts/monitoring/prometheus_graph.js
@@ -3,16 +3,20 @@
import d3 from 'd3';
import statusCodes from '~/lib/utils/http_status';
-import { formatRelevantDigits } from '~/lib/utils/number_utils';
+import Deployments from './deployments';
+import '../lib/utils/common_utils';
+import { formatRelevantDigits } from '../lib/utils/number_utils';
import '../flash';
+import {
+ dateFormat,
+ timeFormat,
+} from './constants';
const prometheusContainer = '.prometheus-container';
const prometheusParentGraphContainer = '.prometheus-graphs';
const prometheusGraphsContainer = '.prometheus-graph';
const prometheusStatesContainer = '.prometheus-state';
const metricsEndpoint = 'metrics.json';
-const timeFormat = d3.time.format('%H:%M');
-const dayFormat = d3.time.format('%b %e, %a');
const bisectDate = d3.bisector(d => d.time).left;
const extraAddedWidthParent = 100;
@@ -36,6 +40,7 @@ class PrometheusGraph {
this.width = parentContainerWidth - this.margin.left - this.margin.right;
this.height = this.originalHeight - this.margin.top - this.margin.bottom;
this.backOffRequestCounter = 0;
+ this.deployments = new Deployments(this.width, this.height);
this.configureGraph();
this.init();
} else {
@@ -74,6 +79,12 @@ class PrometheusGraph {
$(prometheusParentGraphContainer).show();
this.transformData(metricsResponse);
this.createGraph();
+
+ const firstMetricData = this.graphSpecificProperties[
+ Object.keys(this.graphSpecificProperties)[0]
+ ].data;
+
+ this.deployments.init(firstMetricData);
}
});
}
@@ -96,6 +107,7 @@ class PrometheusGraph {
.attr('width', this.width + this.margin.left + this.margin.right)
.attr('height', this.height + this.margin.bottom + this.margin.top)
.append('g')
+ .attr('class', 'graph-container')
.attr('transform', `translate(${this.margin.left},${this.margin.top})`);
const axisLabelContainer = d3.select(prometheusGraphContainer)
@@ -116,6 +128,7 @@ class PrometheusGraph {
.scale(y)
.ticks(this.commonGraphProperties.axis_no_ticks)
.tickSize(-this.width)
+ .outerTickSize(0)
.orient('left');
this.createAxisLabelContainers(axisLabelContainer, key);
@@ -248,7 +261,8 @@ class PrometheusGraph {
const d1 = currentGraphProps.data[overlayIndex];
const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay;
const currentData = evalTime ? d1 : d0;
- const currentTimeCoordinate = currentGraphProps.xScale(currentData.time);
+ const currentTimeCoordinate = Math.floor(currentGraphProps.xScale(currentData.time));
+ const currentDeployXPos = this.deployments.mouseOverDeployInfo(currentXCoordinate, key);
const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`;
const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value));
const maxMetricValue = currentGraphProps.yScale(maxValueFromData);
@@ -256,13 +270,12 @@ class PrometheusGraph {
// Clear up all the pieces of the flag
d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove();
d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .text-metric`).remove();
+ d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric:not(.deploy-info-rect)`).remove();
const currentChart = d3.select(currentPrometheusGraphContainer).select('g');
currentChart.append('line')
- .attr('class', 'selected-metric-line')
.attr({
+ class: `${currentDeployXPos ? 'hidden' : ''} selected-metric-line`,
x1: currentTimeCoordinate,
y1: currentGraphProps.yScale(0),
x2: currentTimeCoordinate,
@@ -272,33 +285,45 @@ class PrometheusGraph {
currentChart.append('circle')
.attr('class', 'circle-metric')
.attr('fill', currentGraphProps.line_color)
- .attr('cx', currentTimeCoordinate)
+ .attr('cx', currentDeployXPos || currentTimeCoordinate)
.attr('cy', currentGraphProps.yScale(currentData.value))
.attr('r', this.commonGraphProperties.circle_radius_metric);
+ if (currentDeployXPos) return;
+
// The little box with text
- const rectTextMetric = currentChart.append('g')
- .attr('class', 'rect-text-metric')
- .attr('translate', `(${currentTimeCoordinate}, ${currentGraphProps.yScale(currentData.value)})`);
+ const rectTextMetric = currentChart.append('svg')
+ .attr({
+ class: 'rect-text-metric',
+ x: currentTimeCoordinate,
+ y: 0,
+ });
rectTextMetric.append('rect')
- .attr('class', 'rect-metric')
- .attr('x', currentTimeCoordinate + 10)
- .attr('y', maxMetricValue)
- .attr('width', this.commonGraphProperties.rect_text_width)
- .attr('height', this.commonGraphProperties.rect_text_height);
+ .attr({
+ class: 'rect-metric',
+ x: 4,
+ y: 1,
+ rx: 2,
+ width: this.commonGraphProperties.rect_text_width,
+ height: this.commonGraphProperties.rect_text_height,
+ });
rectTextMetric.append('text')
- .attr('class', 'text-metric')
- .attr('x', currentTimeCoordinate + 35)
- .attr('y', maxMetricValue + 35)
+ .attr({
+ class: 'text-metric text-metric-bold',
+ x: 8,
+ y: 35,
+ })
.text(timeFormat(currentData.time));
rectTextMetric.append('text')
- .attr('class', 'text-metric-date')
- .attr('x', currentTimeCoordinate + 15)
- .attr('y', maxMetricValue + 15)
- .text(dayFormat(currentData.time));
+ .attr({
+ class: 'text-metric-date',
+ x: 8,
+ y: 15,
+ })
+ .text(dateFormat(currentData.time));
let currentMetricValue = formatRelevantDigits(currentData.value);
if (key === 'cpu_values') {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 974fb0d83da..87f03a40eba 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -4,6 +4,7 @@
/* global ResolveService */
/* global mrRefreshWidgetUrl */
+import $ from 'jquery';
import Cookies from 'js-cookie';
import CommentTypeToggle from './comment_type_toggle';
@@ -16,6 +17,10 @@ require('vendor/jquery.caret'); // required by jquery.atwho
require('vendor/jquery.atwho');
require('./task_list');
+const normalizeNewlines = function(str) {
+ return str.replace(/\r\n/g, '\n');
+};
+
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
@@ -42,13 +47,17 @@ require('./task_list');
this.refresh = bind(this.refresh, this);
this.keydownNoteText = bind(this.keydownNoteText, this);
this.toggleCommitList = bind(this.toggleCommitList, this);
+
this.notes_url = notes_url;
this.note_ids = note_ids;
+ // Used to keep track of updated notes while people are editing things
+ this.updatedNotesTrackingMap = {};
this.last_fetched_at = last_fetched_at;
this.noteable_url = document.URL;
this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge"));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
+
this.cleanBinding();
this.addBinding();
this.setPollingInterval();
@@ -128,7 +137,7 @@ require('./task_list');
$(document).off("click", ".js-discussion-reply-button");
$(document).off("click", ".js-add-diff-note-button");
$(document).off("visibilitychange");
- $(document).off("keyup", ".js-note-text");
+ $(document).off("keyup input", ".js-note-text");
$(document).off("click", ".js-note-target-reopen");
$(document).off("click", ".js-note-target-close");
$(document).off("click", ".js-note-discard");
@@ -267,20 +276,20 @@ require('./task_list');
return this.initRefresh();
};
- Notes.prototype.handleCreateChanges = function(note) {
+ Notes.prototype.handleCreateChanges = function(noteEntity) {
var votesBlock;
- if (typeof note === 'undefined') {
+ if (typeof noteEntity === 'undefined') {
return;
}
- if (note.commands_changes) {
- if ('merge' in note.commands_changes) {
+ if (noteEntity.commands_changes) {
+ if ('merge' in noteEntity.commands_changes) {
$.get(mrRefreshWidgetUrl);
}
- if ('emoji_award' in note.commands_changes) {
+ if ('emoji_award' in noteEntity.commands_changes) {
votesBlock = $('.js-awards-block').eq(0);
- gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.commands_changes.emoji_award);
+ gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
return gl.awardsHandler.scrollToAwards();
}
}
@@ -292,41 +301,76 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
- Notes.prototype.renderNote = function(note, $form) {
- var $notesList;
- if (note.discussion_html != null) {
- return this.renderDiscussionNote(note, $form);
+ Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) {
+ if (noteEntity.discussion_html != null) {
+ return this.renderDiscussionNote(noteEntity, $form);
}
- if (!note.valid) {
- if (note.errors.commands_only) {
- new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
+ if (!noteEntity.valid) {
+ if (noteEntity.errors.commands_only) {
+ new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
this.refresh();
}
return;
}
- if (this.isNewNote(note)) {
- this.note_ids.push(note.id);
+ const $note = $notesList.find(`#note_${noteEntity.id}`);
+ if (this.isNewNote(noteEntity)) {
+ this.note_ids.push(noteEntity.id);
- $notesList = window.$('ul.main-notes-list');
- Notes.animateAppendNote(note.html, $notesList);
+ const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
// Update datetime format on the recent note
- gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
+ gl.utils.localTimeAgo($newNote.find('.js-timeago'), false);
this.collapseLongCommitList();
this.taskList.init();
this.refresh();
return this.updateNotesCount(1);
}
+ // The server can send the same update multiple times so we need to make sure to only update once per actual update.
+ else if (this.isUpdatedNote(noteEntity, $note)) {
+ const isEditing = $note.hasClass('is-editing');
+ const initialContent = normalizeNewlines(
+ $note.find('.original-note-content').text().trim()
+ );
+ const $textarea = $note.find('.js-note-text');
+ const currentContent = $textarea.val();
+ // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
+ const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
+ const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote;
+
+ if (isEditing && isTextareaUntouched) {
+ $textarea.val(noteEntity.note);
+ this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
+ }
+ else if (isEditing && !isTextareaUntouched) {
+ this.putConflictEditWarningInPlace(noteEntity, $note);
+ this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
+ }
+ else {
+ const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
+
+ // Update datetime format on the recent note
+ gl.utils.localTimeAgo($updatedNote.find('.js-timeago'), false);
+ }
+ }
};
/*
Check if note does not exists on page
*/
- Notes.prototype.isNewNote = function(note) {
- return $.inArray(note.id, this.note_ids) === -1;
+ Notes.prototype.isNewNote = function(noteEntity) {
+ return $.inArray(noteEntity.id, this.note_ids) === -1;
+ };
+
+ Notes.prototype.isUpdatedNote = function(noteEntity, $note) {
+ // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
+ const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
+ const currentNoteText = normalizeNewlines(
+ $note.find('.original-note-content').text().trim()
+ );
+ return sanitizedNoteNote !== currentNoteText;
};
Notes.prototype.isParallelView = function() {
@@ -339,31 +383,31 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
- Notes.prototype.renderDiscussionNote = function(note, $form) {
+ Notes.prototype.renderDiscussionNote = function(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer;
- if (!this.isNewNote(note)) {
+ if (!this.isNewNote(noteEntity)) {
return;
}
- this.note_ids.push(note.id);
- form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']");
+ this.note_ids.push(noteEntity.id);
+ form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']");
row = form.closest("tr");
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
// is this the first note of discussion?
- discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`);
+ discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
discussionContainer = form.closest('.discussion').find('.notes');
}
if (discussionContainer.length === 0) {
- if (note.diff_discussion_html) {
- var $discussion = $(note.diff_discussion_html).renderGFM();
+ if (noteEntity.diff_discussion_html) {
+ var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
// insert the note and the reply button after the temp row
row.after($discussion);
} else {
// Merge new discussion HTML in
- var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]');
+ var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]');
var contentContainerClass = '.' + $notes.closest('.notes_content')
.attr('class')
.split(' ')
@@ -373,17 +417,18 @@ require('./task_list');
}
}
// Init discussion on 'Discussion' page if it is merge request page
- if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) {
- Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list'));
+ const page = $('body').attr('data-page');
+ if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) {
+ Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
} else {
// append new note to all matching discussions
- Notes.animateAppendNote(note.html, discussionContainer);
+ Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) {
+ if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
gl.diffNotesCompileComponents();
- this.renderDiscussionAvatar(diffAvatarContainer, note);
+ this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
}
gl.utils.localTimeAgo($('.js-timeago'), false);
@@ -397,13 +442,13 @@ require('./task_list');
.get(0);
};
- Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, note) {
+ Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, noteEntity) {
var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
avatarHolder = document.createElement('diff-note-avatars');
- avatarHolder.setAttribute('discussion-id', note.discussion_id);
+ avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
diffAvatarContainer.append(avatarHolder);
@@ -550,16 +595,16 @@ require('./task_list');
Updates the current note field.
*/
- Notes.prototype.updateNote = function(_xhr, note, _status) {
+ Notes.prototype.updateNote = function(_xhr, noteEntity, _status) {
var $html, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
- $html = $(note.html);
+ $html = $(noteEntity.html);
this.revertNoteEditForm();
gl.utils.localTimeAgo($('.js-timeago', $html));
$html.renderGFM();
$html.find('.js-task-list-container').taskList('enable');
// Find the note's `li` element by ID and replace it with the updated HTML
- $note_li = $('.note-row-' + note.id);
+ $note_li = $('.note-row-' + noteEntity.id);
$note_li.replaceWith($html);
@@ -570,7 +615,7 @@ require('./task_list');
Notes.prototype.checkContentToAllowEditing = function($el) {
var initialContent = $el.find('.original-note-content').text().trim();
- var currentContent = $el.find('.note-textarea').val();
+ var currentContent = $el.find('.js-note-text').val();
var isAllowed = true;
if (currentContent === initialContent) {
@@ -584,7 +629,7 @@ require('./task_list');
gl.utils.scrollToElement($el);
}
- $el.find('.js-edit-warning').show();
+ $el.find('.js-finish-edit-warning').show();
isAllowed = false;
}
@@ -603,7 +648,7 @@ require('./task_list');
var $target = $(e.target);
var $editForm = $(this.getEditFormSelector($target));
var $note = $target.closest('.note');
- var $currentlyEditing = $('.note.is-editting:visible');
+ var $currentlyEditing = $('.note.is-editing:visible');
if ($currentlyEditing.length) {
var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
@@ -615,7 +660,7 @@ require('./task_list');
$note.find('.js-note-attachment-delete').show();
$editForm.addClass('current-note-edit-form');
- $note.addClass('is-editting');
+ $note.addClass('is-editing');
this.putEditFormInPlace($target);
};
@@ -627,21 +672,34 @@ require('./task_list');
Notes.prototype.cancelEdit = function(e) {
e.preventDefault();
- var $target = $(e.target);
- var note = $target.closest('.note');
- note.find('.js-edit-warning').hide();
+ const $target = $(e.target);
+ const $note = $target.closest('.note');
+ const noteId = $note.attr('data-note-id');
+
this.revertNoteEditForm($target);
- return this.removeNoteEditForm(note);
+
+ if (this.updatedNotesTrackingMap[noteId]) {
+ const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
+ $note.replaceWith($newNote);
+ this.updatedNotesTrackingMap[noteId] = null;
+
+ // Update datetime format on the recent note
+ gl.utils.localTimeAgo($newNote.find('.js-timeago'), false);
+ }
+ else {
+ $note.find('.js-finish-edit-warning').hide();
+ this.removeNoteEditForm($note);
+ }
};
Notes.prototype.revertNoteEditForm = function($target) {
- $target = $target || $('.note.is-editting:visible');
+ $target = $target || $('.note.is-editing:visible');
var selector = this.getEditFormSelector($target);
var $editForm = $(selector);
$editForm.insertBefore('.notes-form');
$editForm.find('.js-comment-button').enable();
- $editForm.find('.js-edit-warning').hide();
+ $editForm.find('.js-finish-edit-warning').hide();
};
Notes.prototype.getEditFormSelector = function($el) {
@@ -654,11 +712,11 @@ require('./task_list');
return selector;
};
- Notes.prototype.removeNoteEditForm = function(note) {
- var form = note.find('.current-note-edit-form');
- note.removeClass('is-editting');
+ Notes.prototype.removeNoteEditForm = function($note) {
+ var form = $note.find('.current-note-edit-form');
+ $note.removeClass('is-editing');
form.removeClass('current-note-edit-form');
- form.find('.js-edit-warning').hide();
+ form.find('.js-finish-edit-warning').hide();
// Replace markdown textarea text with original note text.
return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note'));
};
@@ -683,9 +741,9 @@ require('./task_list');
// to remove all. Using $(".note[id='noteId']") ensure we get all the notes,
// where $("#noteId") would return only one.
return function(i, el) {
- var note, notes;
- note = $(el);
- notes = note.closest(".discussion-notes");
+ var $note, $notes;
+ $note = $(el);
+ $notes = $note.closest(".discussion-notes");
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -693,18 +751,18 @@ require('./task_list');
}
}
- note.remove();
+ $note.remove();
// check if this is the last note for this line
- if (notes.find(".note").length === 0) {
- var notesTr = notes.closest("tr");
+ if ($notes.find(".note").length === 0) {
+ var notesTr = $notes.closest("tr");
// "Discussions" tab
- notes.closest(".timeline-entry").remove();
+ $notes.closest(".timeline-entry").remove();
// The notes tr can contain multiple lists of notes, like on the parallel diff
if (notesTr.find('.discussion-notes').length > 1) {
- notes.remove();
+ $notes.remove();
} else {
notesTr.remove();
}
@@ -723,12 +781,11 @@ require('./task_list');
*/
Notes.prototype.removeAttachment = function() {
- var note;
- note = $(this).closest(".note");
- note.find(".note-attachment").remove();
- note.find(".note-body > .note-text").show();
- note.find(".note-header").show();
- return note.find(".current-note-edit-form").remove();
+ const $note = $(this).closest(".note");
+ $note.find(".note-attachment").remove();
+ $note.find(".note-body > .note-text").show();
+ $note.find(".note-header").show();
+ return $note.find(".current-note-edit-form").remove();
};
/*
@@ -1004,6 +1061,19 @@ require('./task_list');
$editForm.find('.referenced-users').hide();
};
+ Notes.prototype.putConflictEditWarningInPlace = function(noteEntity, $note) {
+ if ($note.find('.js-conflict-edit-warning').length === 0) {
+ const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger">
+ This comment has changed since you started editing, please review the
+ <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer">
+ updated comment
+ </a>
+ to ensure information is not lost
+ </div>`);
+ $alert.insertAfter($note.find('.note-text'));
+ }
+ };
+
Notes.prototype.updateNotesCount = function(updateCount) {
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
};
@@ -1064,11 +1134,20 @@ require('./task_list');
return $form;
};
- Notes.animateAppendNote = function(noteHTML, $notesList) {
- const $note = window.$(noteHTML);
+ Notes.animateAppendNote = function(noteHtml, $notesList) {
+ const $note = $(noteHtml);
$note.addClass('fade-in').renderGFM();
$notesList.append($note);
+ return $note;
+ };
+
+ Notes.animateUpdateNote = function(noteHtml, $note) {
+ const $updatedNote = $(noteHtml);
+
+ $updatedNote.addClass('fade-in').renderGFM();
+ $note.replaceWith($updatedNote);
+ return $updatedNote;
};
return Notes;
diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js
deleted file mode 100644
index 203485f2990..00000000000
--- a/app/assets/javascripts/pipelines/components/stage.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/* global Flash */
-import StatusIconEntityMap from '../../ci_status_icons';
-
-export default {
- props: {
- stage: {
- type: Object,
- required: true,
- },
- },
-
- data() {
- return {
- builds: '',
- spinner: '<span class="fa fa-spinner fa-spin"></span>',
- };
- },
-
- updated() {
- if (this.builds) {
- this.stopDropdownClickPropagation();
- }
- },
-
- methods: {
- fetchBuilds(e) {
- const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
-
- if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
-
- return this.$http.get(this.stage.dropdown_path)
- .then((response) => {
- this.builds = JSON.parse(response.body).html;
- })
- .catch(() => {
- // If dropdown is opened we'll close it.
- if (this.$el.classList.contains('open')) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
-
- const flash = new Flash('Something went wrong on our end.');
- return flash;
- });
- },
-
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
- .on('click', (e) => {
- e.stopPropagation();
- });
- },
- },
- computed: {
- buildsOrSpinner() {
- return this.builds ? this.builds : this.spinner;
- },
- dropdownClass() {
- if (this.builds) return 'js-builds-dropdown-container';
- return 'js-builds-dropdown-loading builds-dropdown-loading';
- },
- buildStatus() {
- return `Build: ${this.stage.status.label}`;
- },
- tooltip() {
- return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
- },
- triggerButtonClass() {
- return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
- },
- svgHTML() {
- return StatusIconEntityMap[this.stage.status.icon];
- },
- },
- template: `
- <div>
- <button
- @click="fetchBuilds($event)"
- :class="triggerButtonClass"
- :title="stage.title"
- data-placement="top"
- data-toggle="dropdown"
- type="button"
- :aria-label="stage.title"
- ref="dropdown">
- <span
- v-html="svgHTML"
- aria-hidden="true">
- </span>
- <i
- class="fa fa-caret-down"
- aria-hidden="true" />
- </button>
- <ul
- ref="dropdown-content"
- class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
- <div
- class="arrow-up"
- aria-hidden="true"></div>
- <div
- :class="dropdownClass"
- class="js-builds-dropdown-list scrollable-menu"
- v-html="buildsOrSpinner">
- </div>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
new file mode 100644
index 00000000000..2e485f951a1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -0,0 +1,173 @@
+<script>
+
+/**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+/* global Flash */
+import StatusIconEntityMap from '../../ci_status_icons';
+
+export default {
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: '',
+ endpoint: this.stage.dropdown_path,
+ };
+ },
+
+ updated() {
+ if (this.dropdownContent.length > 0) {
+ this.stopDropdownClickPropagation();
+ }
+ },
+
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown &&
+ this.isDropdownOpen() &&
+ !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
+ },
+
+ methods: {
+ onClickStage() {
+ if (!this.isDropdownOpen()) {
+ this.isLoading = true;
+ this.fetchJobs();
+ }
+ },
+
+ fetchJobs() {
+ this.$http.get(this.endpoint)
+ .then((response) => {
+ this.dropdownContent = response.json().html;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.closeDropdown();
+ this.isLoading = false;
+
+ const flash = new Flash('Something went wrong on our end.');
+ return flash;
+ });
+ },
+
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
+ .on('click', (e) => {
+ e.stopPropagation();
+ });
+ },
+
+ closeDropdown() {
+ if (this.isDropdownOpen()) {
+ $(this.$refs.dropdown).dropdown('toggle');
+ }
+ },
+
+ isDropdownOpen() {
+ return this.$el.classList.contains('open');
+ },
+ },
+
+ computed: {
+ dropdownClass() {
+ return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
+ },
+
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
+ },
+
+ svgIcon() {
+ return StatusIconEntityMap[this.stage.status.icon];
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown">
+ <button
+ :class="triggerButtonClass"
+ @click="onClickStage"
+ class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button"
+ :title="stage.title"
+ data-placement="top"
+ data-toggle="dropdown"
+ type="button"
+ id="stageDropdown"
+ aria-haspopup="true"
+ aria-expanded="false">
+
+ <span
+ v-html="svgIcon"
+ aria-hidden="true"
+ :aria-label="stage.title">
+ </span>
+
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true">
+ </i>
+ </button>
+
+ <ul
+ class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
+ aria-labelledby="stageDropdown">
+
+ <li
+ :class="dropdownClass"
+ class="js-builds-dropdown-list scrollable-menu">
+
+ <div
+ class="text-center"
+ v-if="isLoading">
+ <i
+ class="fa fa-spin fa-spinner"
+ aria-hidden="true"
+ aria-label="Loading">
+ </i>
+ </div>
+
+ <ul
+ v-else
+ v-html="dropdownContent">
+ </ul>
+ </li>
+ </ul>
+ </div>
+</script>
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
index 93d4818231f..934bd7deb31 100644
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ b/app/assets/javascripts/pipelines/pipelines.js
@@ -49,6 +49,7 @@ export default {
isLoading: false,
hasError: false,
isMakingRequest: false,
+ updateGraphDropdown: false,
};
},
@@ -198,15 +199,21 @@ export default {
this.store.storePagination(response.headers);
this.isLoading = false;
+ this.updateGraphDropdown = true;
},
errorCallback() {
this.hasError = true;
this.isLoading = false;
+ this.updateGraphDropdown = false;
},
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
+
+ if (isMakingRequest) {
+ this.updateGraphDropdown = false;
+ }
},
},
@@ -263,7 +270,9 @@ export default {
<pipelines-table-component
:pipelines="state.pipelines"
- :service="service"/>
+ :service="service"
+ :update-graph-dropdown="updateGraphDropdown"
+ />
</div>
<gl-pagination
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 0344ce9ffb4..68cf9ced3ef 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -30,7 +30,7 @@
$els.each((function(_this) {
return function(i, dropdown) {
var options = {};
- var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove;
+ var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove;
$dropdown = $(dropdown);
options.projectId = $dropdown.data('project-id');
options.groupId = $dropdown.data('group-id');
@@ -38,11 +38,11 @@
options.todoFilter = $dropdown.data('todo-filter');
options.todoStateFilter = $dropdown.data('todo-state-filter');
showNullUser = $dropdown.data('null-user');
+ defaultNullUser = $dropdown.data('null-user-default');
showMenuAbove = $dropdown.data('showMenuAbove');
showAnyUser = $dropdown.data('any-user');
firstUser = $dropdown.data('first-user');
options.authorId = $dropdown.data('author-id');
- selectedId = $dropdown.data('selected');
defaultLabel = $dropdown.data('default-label');
issueURL = $dropdown.data('issueUpdate');
$selectbox = $dropdown.closest('.selectbox');
@@ -51,6 +51,8 @@
$value = $block.find('.value');
$collapsedSidebar = $block.find('.sidebar-collapsed-user');
$loading = $block.find('.block-loading').fadeOut();
+ selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
+ selectedId = $dropdown.data('selected') || selectedIdDefault;
var updateIssueBoardsIssue = function () {
$loading.removeClass('hidden').fadeIn();
@@ -186,12 +188,14 @@
fieldName: $dropdown.data('field-name'),
toggleLabel: function(selected, el) {
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
+ $dropdown.find('.dropdown-toggle-text').removeClass('is-default');
if (selected.text) {
return selected.text;
} else {
return selected.name;
}
} else {
+ $dropdown.find('.dropdown-toggle-text').addClass('is-default');
return defaultLabel;
}
},
@@ -204,13 +208,14 @@
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(user, $el, e) {
- var isIssueIndex, isMRIndex, page, selected;
+ var isIssueIndex, isMRIndex, page, selected, isSelecting;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ isSelecting = (user.id !== selectedId);
+ selectedId = isSelecting ? user.id : selectedIdDefault;
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
- selectedId = user.id;
if (selectedId === gon.current_user_id) {
$('.assign-to-me-link').hide();
} else {
@@ -221,12 +226,11 @@
if ($el.closest('.add-issues-modal').length) {
gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- selectedId = user.id;
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if (user.id) {
+ if (user.id && isSelecting) {
gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
id: user.id,
username: user.username,
@@ -248,6 +252,9 @@
},
opened: function(e) {
const $el = $(e.currentTarget);
+ if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault;
+ }
$el.find('.is-active').removeClass('is-active');
$el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
},
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js
index afd8d7acf6b..48a39f18112 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js
@@ -10,13 +10,18 @@ export default {
pipelines: {
type: Array,
required: true,
- default: () => ([]),
},
service: {
type: Object,
required: true,
},
+
+ updateGraphDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
components: {
@@ -40,7 +45,9 @@ export default {
v-bind:model="model">
<tr is="pipelines-table-row-component"
:pipeline="model"
- :service="service"></tr>
+ :service="service"
+ :update-graph-dropdown="updateGraphDropdown"
+ />
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
index 79806bc7204..fbae85c85f6 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
@@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue';
import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions';
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
import PipelinesStatusComponent from '../../pipelines/components/status';
-import PipelinesStageComponent from '../../pipelines/components/stage';
+import PipelinesStageComponent from '../../pipelines/components/stage.vue';
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit';
@@ -24,6 +24,12 @@ export default {
type: Object,
required: true,
},
+
+ updateGraphDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
components: {
@@ -213,7 +219,10 @@ export default {
<div class="stage-container dropdown js-mini-pipeline-graph"
v-if="pipeline.details.stages.length > 0"
v-for="stage in pipeline.details.stages">
- <dropdown-stage :stage="stage"/>
+
+ <dropdown-stage
+ :stage="stage"
+ :update-dropdown="updateGraphDropdown"/>
</div>
</td>
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 1313ea25c2a..73ded9f30d4 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -390,7 +390,8 @@
&::before {
position: absolute;
left: 6px;
- top: 6px;
+ top: 50%;
+ transform: translateY(-50%);
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
text-rendering: auto;
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 1b4694377b3..feefaad8a15 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -425,12 +425,6 @@
float: right;
}
-.diffs {
- .content-block {
- border-bottom: none;
- }
-}
-
.files-changed {
border-bottom: none;
}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 72e7d42858d..026d35295d7 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -157,7 +157,8 @@
.prometheus-graph {
text {
- fill: $stat-graph-axis-fill;
+ fill: $gl-text-color;
+ stroke-width: 0;
}
.label-axis-text,
@@ -210,27 +211,33 @@
.rect-text-metric {
fill: $white-light;
stroke-width: 1;
- stroke: $black;
+ stroke: $gray-darkest;
}
.rect-axis-text {
fill: $white-light;
}
-.text-metric,
-.text-median-metric,
-.text-metric-usage,
-.text-metric-date {
- fill: $black;
+.text-metric {
+ font-weight: 600;
}
-.text-metric-date {
- font-weight: 200;
+.selected-metric-line {
+ stroke: $gl-gray-dark;
+ stroke-width: 1;
}
-.selected-metric-line {
+.deployment-line {
stroke: $black;
- stroke-width: 1;
+ stroke-width: 2;
+}
+
+.deploy-info-text {
+ dominant-baseline: text-before-edge;
+}
+
+.text-metric-bold {
+ font-weight: 600;
}
.prometheus-state {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 97fab513b01..ad6eb9f6fe0 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -6,7 +6,13 @@
}
.limit-container-width {
- .detail-page-header {
+ .detail-page-header,
+ .page-content-header,
+ .commit-box,
+ .info-well,
+ .notes,
+ .commit-ci-menu,
+ .files-changed {
@extend .fixed-width-container;
}
@@ -36,8 +42,7 @@
}
.diffs {
- .mr-version-controls,
- .files-changed {
+ .mr-version-controls {
@extend .fixed-width-container;
}
}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 2aa52986e0a..b18bbc329c3 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -161,3 +161,86 @@ ul.related-merge-requests > li {
.recaptcha {
margin-bottom: 30px;
}
+
+.new-branch-col {
+ padding-top: 10px;
+}
+
+.create-mr-dropdown-wrap {
+ .btn-group:not(.hide) {
+ display: flex;
+ }
+
+ .js-create-merge-request {
+ flex-grow: 1;
+ flex-shrink: 0;
+ }
+
+ .dropdown-menu {
+ width: 300px;
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ display: none;
+ }
+
+ .dropdown-toggle {
+ .fa-caret-down {
+ pointer-events: none;
+ margin-left: 0;
+ color: inherit;
+ margin-left: 0;
+ }
+ }
+
+ li:not(.divider) {
+ padding: 6px;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-hover-color;
+ color: $white-light;
+ }
+
+ &.droplab-item-selected {
+ .icon-container {
+ i {
+ visibility: visible;
+ }
+ }
+ }
+
+ .icon-container {
+ float: left;
+ padding-left: 6px;
+
+ i {
+ visibility: hidden;
+ }
+ }
+
+ .description {
+ padding-left: 30px;
+ font-size: 13px;
+
+ strong {
+ display: block;
+ font-weight: 600;
+ }
+ }
+ }
+}
+
+@media (min-width: $screen-sm-min) {
+ .new-branch-col {
+ padding-top: 0;
+ text-align: right;
+ }
+
+ .create-mr-dropdown-wrap {
+ .btn-group:not(.hide) {
+ display: inline-block;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index be7193bae04..8dbac76e30a 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -133,3 +133,55 @@
right: 160px;
}
}
+
+.flex-project-members-panel {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ @media (max-width: $screen-sm-min) {
+ display: block;
+
+ .flex-project-title {
+ vertical-align: top;
+ display: inline-block;
+ max-width: 90%;
+ }
+ }
+
+ .flex-project-title {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .badge {
+ height: 17px;
+ line-height: 16px;
+ margin-right: 5px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ }
+
+ .flex-project-members-form {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ margin-left: auto;
+ }
+}
+
+.panel {
+ .panel-heading {
+ .badge {
+ margin-top: 0;
+ }
+
+ @media (max-width: $screen-sm-min) {
+ .badge {
+ margin-right: 0;
+ margin-left: 0;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 6a419384a34..72660113e3c 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -482,6 +482,10 @@
}
}
+.target-branch-select-dropdown-container {
+ position: relative;
+}
+
.assign-to-me-link {
padding-left: 12px;
white-space: nowrap;
@@ -511,7 +515,6 @@
.mr-version-controls {
background: $gray-light;
- border-bottom: 1px solid $border-color;
color: $gl-text-color;
.mr-version-menus-container {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 7cf74502a3a..f89150ebead 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -67,7 +67,7 @@ ul.notes {
}
}
- &.is-editting {
+ &.is-editing {
.note-header,
.note-text,
.edited-text {
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index a4fe652b52f..9115d26c779 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -781,16 +781,11 @@
}
.scrollable-menu {
+ padding: 0;
max-height: 245px;
overflow: auto;
}
- // Loading icon
- .builds-dropdown-loading {
- margin: 0 auto;
- width: 20px;
- }
-
// Action icon on the right
a.ci-action-icon-wrapper {
color: $action-icon-color;
@@ -893,30 +888,29 @@
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
- .arrow-up {
- &::before,
- &::after {
- content: '';
- display: inline-block;
- position: absolute;
- width: 0;
- height: 0;
- border-color: transparent;
- border-style: solid;
- top: -6px;
- left: 2px;
- border-width: 0 5px 6px;
- }
- &::before {
- border-width: 0 5px 5px;
- border-bottom-color: $border-color;
- }
+ &::before,
+ &::after {
+ content: '';
+ display: inline-block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+ top: -6px;
+ left: 2px;
+ border-width: 0 5px 6px;
+ }
- &::after {
- margin-top: 1px;
- border-bottom-color: $white-light;
- }
+ &::before {
+ border-width: 0 5px 5px;
+ border-bottom-color: $border-color;
+ }
+
+ &::after {
+ margin-top: 1px;
+ border-bottom-color: $white-light;
}
}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 04ff2d52b91..b64b89485f7 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -71,7 +71,6 @@
.nav-controls {
width: auto;
min-width: 50%;
- white-space: nowrap;
}
}
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index cbfc4581411..a119934febc 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -1,4 +1,6 @@
class Admin::HooksController < Admin::ApplicationController
+ before_action :hook, only: :edit
+
def index
@hooks = SystemHook.all
@hook = SystemHook.new
@@ -15,15 +17,25 @@ class Admin::HooksController < Admin::ApplicationController
end
end
+ def edit
+ end
+
+ def update
+ if hook.update_attributes(hook_params)
+ flash[:notice] = 'System hook was successfully updated.'
+ redirect_to admin_hooks_path
+ else
+ render 'edit'
+ end
+ end
+
def destroy
- @hook = SystemHook.find(params[:id])
- @hook.destroy
+ hook.destroy
redirect_to admin_hooks_path
end
def test
- @hook = SystemHook.find(params[:hook_id])
data = {
event_name: "project_create",
name: "Ruby",
@@ -32,11 +44,17 @@ class Admin::HooksController < Admin::ApplicationController
owner_name: "Someone",
owner_email: "example@gitlabhq.com"
}
- @hook.execute(data, 'system_hooks')
+ hook.execute(data, 'system_hooks')
redirect_back_or_default
end
+ private
+
+ def hook
+ @hook ||= SystemHook.find(params[:id])
+ end
+
def hook_params
params.require(:hook).permit(
:enable_ssl_verification,
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
new file mode 100644
index 00000000000..3e2a0fe4f8b
--- /dev/null
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -0,0 +1,53 @@
+module MilestoneActions
+ extend ActiveSupport::Concern
+
+ def merge_requests
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_merge_requests_tab", {
+ merge_requests: @milestone.merge_requests,
+ show_project_name: true
+ })
+ end
+ end
+ end
+
+ def participants
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_participants_tab", {
+ users: @milestone.participants
+ })
+ end
+ end
+ end
+
+ def labels
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_labels_tab", {
+ labels: @milestone.labels
+ })
+ end
+ end
+ end
+
+ private
+
+ def tabs_json(partial, data = {})
+ {
+ html: view_to_html_string(partial, data)
+ }
+ end
+
+ def milestone_redirect_path
+ if @project
+ namespace_project_milestone_path(@project.namespace, @project, @milestone)
+ else
+ group_milestone_path(@group, @milestone.safe_title, title: @milestone.title)
+ end
+ end
+end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
new file mode 100644
index 00000000000..c32038d07bf
--- /dev/null
+++ b/app/controllers/concerns/notes_actions.rb
@@ -0,0 +1,136 @@
+module NotesActions
+ include RendersNotes
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authorize_admin_note!, only: [:update, :destroy]
+ end
+
+ def index
+ current_fetched_at = Time.now.to_i
+
+ notes_json = { notes: [], last_fetched_at: current_fetched_at }
+
+ @notes = notes_finder.execute.inc_relations_for_view
+ @notes = prepare_notes_for_rendering(@notes)
+
+ @notes.each do |note|
+ next if note.cross_reference_not_visible_for?(current_user)
+
+ notes_json[:notes] << note_json(note)
+ end
+
+ render json: notes_json
+ end
+
+ def create
+ create_params = note_params.merge(
+ merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
+ in_reply_to_discussion_id: params[:in_reply_to_discussion_id]
+ )
+ @note = Notes::CreateService.new(project, current_user, create_params).execute
+
+ if @note.is_a?(Note)
+ Banzai::NoteRenderer.render([@note], @project, current_user)
+ end
+
+ respond_to do |format|
+ format.json { render json: note_json(@note) }
+ format.html { redirect_back_or_default }
+ end
+ end
+
+ def update
+ @note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
+
+ if @note.is_a?(Note)
+ Banzai::NoteRenderer.render([@note], @project, current_user)
+ end
+
+ respond_to do |format|
+ format.json { render json: note_json(@note) }
+ format.html { redirect_back_or_default }
+ end
+ end
+
+ def destroy
+ if note.editable?
+ Notes::DestroyService.new(project, current_user).execute(note)
+ end
+
+ respond_to do |format|
+ format.js { head :ok }
+ end
+ end
+
+ private
+
+ def note_json(note)
+ attrs = {
+ commands_changes: note.commands_changes
+ }
+
+ if note.persisted?
+ attrs.merge!(
+ valid: true,
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
+ html: note_html(note),
+ note: note.note
+ )
+
+ discussion = note.to_discussion(noteable)
+ unless discussion.individual_note?
+ attrs.merge!(
+ discussion_resolvable: discussion.resolvable?,
+
+ diff_discussion_html: diff_discussion_html(discussion),
+ discussion_html: discussion_html(discussion)
+ )
+ end
+ else
+ attrs.merge!(
+ valid: false,
+ errors: note.errors
+ )
+ end
+
+ attrs
+ end
+
+ def authorize_admin_note!
+ return access_denied! unless can?(current_user, :admin_note, note)
+ end
+
+ def note_params
+ params.require(:note).permit(
+ :project_id,
+ :noteable_type,
+ :noteable_id,
+ :commit_id,
+ :noteable,
+ :type,
+
+ :note,
+ :attachment,
+
+ # LegacyDiffNote
+ :line_code,
+
+ # DiffNote
+ :position
+ )
+ end
+
+ def noteable
+ @noteable ||= notes_finder.target
+ end
+
+ def last_fetched_at
+ request.headers['X-Last-Fetched-At']
+ end
+
+ def notes_finder
+ @notes_finder ||= NotesFinder.new(project, current_user, finder_params)
+ end
+end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index dd21066ac13..41c3114ad1e 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -10,6 +10,8 @@ module RendersNotes
private
def preload_max_access_for_authors(notes, project)
+ return nil unless project
+
user_ids = notes.map(&:author_id)
project.team.max_member_access_for_user_ids(user_ids)
end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index ca6dffe1cc5..ffea712a833 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -5,10 +5,12 @@ module SnippetsActions
end
def raw
+ disposition = params[:inline] == 'false' ? 'attachment' : 'inline'
+
send_data(
convert_line_endings(@snippet.content),
type: 'text/plain; charset=utf-8',
- disposition: 'inline',
+ disposition: disposition,
filename: @snippet.sanitized_file_name
)
end
diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb
index fbf9a026b10..ba5b7d33f87 100644
--- a/app/controllers/concerns/toggle_award_emoji.rb
+++ b/app/controllers/concerns/toggle_award_emoji.rb
@@ -22,7 +22,8 @@ module ToggleAwardEmoji
def to_todoable(awardable)
case awardable
when Note
- awardable.noteable
+ # we don't create todos for personal snippet comments for now
+ awardable.for_personal_snippet? ? nil : awardable.noteable
when MergeRequest, Issue
awardable
when Snippet
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
new file mode 100644
index 00000000000..dec2e27335a
--- /dev/null
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -0,0 +1,27 @@
+module UploadsActions
+ def create
+ link_to_file = UploadService.new(model, params[:file], uploader_class).execute
+
+ respond_to do |format|
+ if link_to_file
+ format.json do
+ render json: { link: link_to_file }
+ end
+ else
+ format.json do
+ render json: 'Invalid file.', status: :unprocessable_entity
+ end
+ end
+ end
+ end
+
+ def show
+ return render_404 unless uploader.exists?
+
+ disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+
+ expires_in 0.seconds, must_revalidate: true, private: true
+
+ send_file uploader.file.path, disposition: disposition
+ end
+end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 43102596201..e52fa766044 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -1,6 +1,8 @@
class Groups::MilestonesController < Groups::ApplicationController
+ include MilestoneActions
+
before_action :group_projects
- before_action :milestone, only: [:show, :update]
+ before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels]
before_action :authorize_admin_milestones!, only: [:new, :create, :update]
def index
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index e2f81b09adc..89f1128ec36 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -89,4 +89,8 @@ class Projects::ApplicationController < ApplicationController
def builds_enabled
return render_404 unless @project.feature_available?(:builds, current_user)
end
+
+ def require_pages_enabled!
+ not_found unless Gitlab.config.pages.enabled
+ end
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 59222637961..1224e9503c9 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -1,11 +1,13 @@
class Projects::ArtifactsController < Projects::ApplicationController
include ExtractsPath
+ include RendersBlob
layout 'project'
before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep]
before_action :extract_ref_name_and_path
before_action :validate_artifacts!
+ before_action :set_path_and_entry, only: [:file, :raw]
def download
if artifacts_file.file_storage?
@@ -16,22 +18,32 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def browse
- directory = params[:path] ? "#{params[:path]}/" : ''
+ @path = params[:path]
+ directory = @path ? "#{@path}/" : ''
@entry = build.artifacts_metadata_entry(directory)
render_404 unless @entry.exists?
end
def file
- entry = build.artifacts_metadata_entry(params[:path])
+ blob = @entry.blob
+ override_max_blob_size(blob)
- if entry.exists?
- send_artifacts_entry(build, entry)
- else
- render_404
+ respond_to do |format|
+ format.html do
+ render 'file'
+ end
+
+ format.json do
+ render_blob_json(blob)
+ end
end
end
+ def raw
+ send_artifacts_entry(build, @entry)
+ end
+
def keep
build.keep_artifacts!
redirect_to namespace_project_build_path(project.namespace, project, build)
@@ -60,7 +72,10 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def build
- @build ||= build_from_id || build_from_ref
+ @build ||= begin
+ build = build_from_id || build_from_ref
+ build&.present(current_user: current_user)
+ end
end
def build_from_id
@@ -77,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController
def artifacts_file
@artifacts_file ||= build.artifacts_file
end
+
+ def set_path_and_entry
+ @path = params[:path]
+ @entry = build.artifacts_metadata_entry(@path)
+
+ render_404 unless @entry.exists?
+ end
end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 840405f38cb..f0f031303d8 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -46,20 +46,28 @@ class Projects::BranchesController < Projects::ApplicationController
SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue
end
- if result[:status] == :success
- @branch = result[:branch]
-
- if redirect_to_autodeploy
- redirect_to(
- url_to_autodeploy_setup(project, branch_name),
- notice: view_context.autodeploy_flash_notice(branch_name))
- else
- redirect_to namespace_project_tree_path(@project.namespace, @project,
- @branch.name)
+ respond_to do |format|
+ format.html do
+ if result[:status] == :success
+ if redirect_to_autodeploy
+ redirect_to url_to_autodeploy_setup(project, branch_name),
+ notice: view_context.autodeploy_flash_notice(branch_name)
+ else
+ redirect_to namespace_project_tree_path(@project.namespace, @project, branch_name)
+ end
+ else
+ @error = result[:message]
+ render action: 'new'
+ end
+ end
+
+ format.json do
+ if result[:status] == :success
+ render json: { name: branch_name, url: namespace_project_tree_url(@project.namespace, @project, branch_name) }
+ else
+ render json: result[:messsage], status: :unprocessable_entity
+ end
end
- else
- @error = result[:message]
- render action: 'new'
end
end
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index d0c44e297e3..f27089b8590 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController
layout "project_settings"
def index
- redirect_to_repository_settings(@project)
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.json do
+ render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json
+ end
+ end
end
def new
@@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
@key = DeployKey.new(deploy_key_params.merge(user: current_user))
unless @key.valid? && @project.deploy_keys << @key
- flash[:alert] = @key.errors.full_messages.join(', ').html_safe
+ flash[:alert] = @key.errors.full_messages.join(', ').html_safe
end
redirect_to_repository_settings(@project)
end
@@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController
def enable
Projects::EnableDeployKeyService.new(@project, current_user, params).execute
- redirect_to_repository_settings(@project)
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.json { head :ok }
+ end
end
def disable
@@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController
return render_404 unless deploy_key_project
deploy_key_project.destroy!
- redirect_to_repository_settings(@project)
+
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.json { head :ok }
+ end
end
protected
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
new file mode 100644
index 00000000000..c319671456d
--- /dev/null
+++ b/app/controllers/projects/deployments_controller.rb
@@ -0,0 +1,18 @@
+class Projects::DeploymentsController < Projects::ApplicationController
+ before_action :authorize_read_environment!
+ before_action :authorize_read_deployment!
+
+ def index
+ deployments = environment.deployments.reorder(created_at: :desc)
+ deployments = deployments.where('created_at > ?', params[:after].to_time) if params[:after]&.to_time
+
+ render json: { deployments: DeploymentSerializer.new(user: @current_user, project: project)
+ .represent_concise(deployments) }
+ end
+
+ private
+
+ def environment
+ @environment ||= project.environments.find(params[:environment_id])
+ end
+end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 1e41f980f31..86d13a0d222 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -1,6 +1,7 @@
class Projects::HooksController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
+ before_action :hook, only: :edit
respond_to :html
@@ -17,6 +18,18 @@ class Projects::HooksController < Projects::ApplicationController
redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
end
+ def edit
+ end
+
+ def update
+ if hook.update_attributes(hook_params)
+ flash[:notice] = 'Hook was successfully updated.'
+ redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+ else
+ render 'edit'
+ end
+ end
+
def test
if !@project.empty_repo?
status, message = TestHookService.new.execute(hook, current_user)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index cbf67137261..af9157bfbb5 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
- :related_branches, :can_create_branch, :rendered_title]
+ :related_branches, :can_create_branch, :rendered_title, :create_merge_request]
# Allow read any issue
before_action :authorize_read_issue!, only: [:show, :rendered_title]
@@ -22,6 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow modify issue
before_action :authorize_update_issue!, only: [:edit, :update]
+ # Allow create a new branch and empty WIP merge request from current issue
+ before_action :authorize_create_merge_request!, only: [:create_merge_request]
+
respond_to :html
def index
@@ -191,7 +194,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.json do
- render json: { can_create_branch: can_create }
+ render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? }
end
end
end
@@ -201,6 +204,16 @@ class Projects::IssuesController < Projects::ApplicationController
render json: { title: view_context.markdown_field(@issue, :title) }
end
+ def create_merge_request
+ result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
+
+ if result[:status] == :success
+ render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
+ else
+ render json: result[:messsage], status: :unprocessable_entity
+ end
+ end
+
protected
def issue
@@ -224,6 +237,10 @@ class Projects::IssuesController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_issue, @project)
end
+ def authorize_create_merge_request!
+ return render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
+ end
+
def module_enabled
return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker?
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 09dc8b38229..a63b7ff0bed 100755
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -120,7 +120,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
define_diff_comment_vars
else
build_merge_request
- @diffs = @merge_request.diffs(diff_options)
+ @compare = @merge_request
+ @diffs = @compare.diffs(diff_options)
@diff_notes_disabled = true
end
@@ -584,12 +585,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
- @diffs =
+ @compare =
if @start_sha
- @merge_request_diff.compare_with(@start_sha).diffs(diff_options)
+ @merge_request_diff.compare_with(@start_sha)
else
- @merge_request_diff.diffs(diff_options)
+ @merge_request_diff
end
+
+ @diffs = @compare.diffs(diff_options)
end
def define_diff_comment_vars
@@ -598,11 +601,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
noteable_id: @merge_request.id
}
- @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha
+ @diff_notes_disabled = false
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
- @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@merge_request_diff.diff_refs)
+ @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs)
@notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index d0dd524c484..c56bce19eee 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -1,12 +1,14 @@
class Projects::MilestonesController < Projects::ApplicationController
+ include MilestoneActions
+
before_action :module_enabled
- before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests]
+ before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests, :merge_requests, :participants, :labels]
# Allow read any milestone
before_action :authorize_read_milestone!
# Allow admin milestone
- before_action :authorize_admin_milestone!, except: [:index, :show]
+ before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels]
respond_to :html
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 405ea3c0a4f..37f51b2ebe3 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,68 +1,22 @@
class Projects::NotesController < Projects::ApplicationController
- include RendersNotes
+ include NotesActions
include ToggleAwardEmoji
- # Authorize
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
- before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
- def index
- current_fetched_at = Time.now.to_i
-
- notes_json = { notes: [], last_fetched_at: current_fetched_at }
-
- @notes = notes_finder.execute.inc_relations_for_view
- @notes = prepare_notes_for_rendering(@notes)
-
- @notes.each do |note|
- next if note.cross_reference_not_visible_for?(current_user)
-
- notes_json[:notes] << note_json(note)
- end
-
- render json: notes_json
- end
-
+ #
+ # This is a fix to make spinach feature tests passing:
+ # Controller actions are returned from AbstractController::Base and methods of parent classes are
+ # excluded in order to return only specific controller related methods.
+ # That is ok for the app (no :create method in ancestors)
+ # but fails for tests because there is a :create method on FactoryGirl (one of the ancestors)
+ #
+ # see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78
+ #
def create
- create_params = note_params.merge(
- merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
- in_reply_to_discussion_id: params[:in_reply_to_discussion_id]
- )
- @note = Notes::CreateService.new(project, current_user, create_params).execute
-
- if @note.is_a?(Note)
- Banzai::NoteRenderer.render([@note], @project, current_user)
- end
-
- respond_to do |format|
- format.json { render json: note_json(@note) }
- format.html { redirect_back_or_default }
- end
- end
-
- def update
- @note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
-
- if @note.is_a?(Note)
- Banzai::NoteRenderer.render([@note], @project, current_user)
- end
-
- respond_to do |format|
- format.json { render json: note_json(@note) }
- format.html { redirect_back_or_default }
- end
- end
-
- def destroy
- if note.editable?
- Notes::DestroyService.new(project, current_user).execute(note)
- end
-
- respond_to do |format|
- format.js { head :ok }
- end
+ super
end
def delete_attachment
@@ -110,7 +64,7 @@ class Projects::NotesController < Projects::ApplicationController
def note_html(note)
render_to_string(
- "projects/notes/_note",
+ "shared/notes/_note",
layout: false,
formats: [:html],
locals: { note: note }
@@ -152,76 +106,11 @@ class Projects::NotesController < Projects::ApplicationController
)
end
- def note_json(note)
- attrs = {
- commands_changes: note.commands_changes
- }
-
- if note.persisted?
- attrs.merge!(
- valid: true,
- id: note.id,
- discussion_id: note.discussion_id(noteable),
- html: note_html(note),
- note: note.note
- )
-
- discussion = note.to_discussion(noteable)
- unless discussion.individual_note?
- attrs.merge!(
- discussion_resolvable: discussion.resolvable?,
-
- diff_discussion_html: diff_discussion_html(discussion),
- discussion_html: discussion_html(discussion)
- )
- end
- else
- attrs.merge!(
- valid: false,
- errors: note.errors
- )
- end
-
- attrs
- end
-
- def authorize_admin_note!
- return access_denied! unless can?(current_user, :admin_note, note)
+ def finder_params
+ params.merge(last_fetched_at: last_fetched_at)
end
def authorize_resolve_note!
return access_denied! unless can?(current_user, :resolve_note, note)
end
-
- def note_params
- params.require(:note).permit(
- :project_id,
- :noteable_type,
- :noteable_id,
- :commit_id,
- :noteable,
- :type,
-
- :note,
- :attachment,
-
- # LegacyDiffNote
- :line_code,
-
- # DiffNote
- :position
- )
- end
-
- def notes_finder
- @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at))
- end
-
- def noteable
- @noteable ||= notes_finder.target
- end
-
- def last_fetched_at
- request.headers['X-Last-Fetched-At']
- end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index fbd18b68141..93b2c180810 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -1,6 +1,7 @@
class Projects::PagesController < Projects::ApplicationController
layout 'project_settings'
+ before_action :require_pages_enabled!
before_action :authorize_read_pages!, only: [:show]
before_action :authorize_update_pages!, except: [:show]
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index b8c253f6ae3..3a93977fd27 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -1,6 +1,7 @@
class Projects::PagesDomainsController < Projects::ApplicationController
layout 'project_settings'
+ before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show]
before_action :domain, only: [:show, :destroy]
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 1780cc0233c..454b8ee17af 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -9,19 +9,19 @@ class Projects::PipelinesController < Projects::ApplicationController
def index
@scope = params[:scope]
@pipelines = PipelinesFinder
- .new(project)
- .execute(scope: @scope)
+ .new(project, scope: @scope)
+ .execute
.page(params[:page])
.per(30)
@running_count = PipelinesFinder
- .new(project).execute(scope: 'running').count
+ .new(project, scope: 'running').execute.count
@pending_count = PipelinesFinder
- .new(project).execute(scope: 'pending').count
+ .new(project, scope: 'pending').execute.count
@finished_count = PipelinesFinder
- .new(project).execute(scope: 'finished').count
+ .new(project, scope: 'finished').execute.count
@pipelines_count = PipelinesFinder
.new(project).execute.count
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index a0b08ad130f..a02cc477e08 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController
return if cached_blob?
- if @blob.valid_lfs_pointer?
+ if @blob.stored_externally?
send_lfs_object
else
send_git_blob @repository, @blob
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 61686499bd3..6966a7c5fee 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -1,33 +1,11 @@
class Projects::UploadsController < Projects::ApplicationController
+ include UploadsActions
+
skip_before_action :project, :repository,
if: -> { action_name == 'show' && image_or_video? }
before_action :authorize_upload_file!, only: [:create]
- def create
- link_to_file = ::Projects::UploadService.new(project, params[:file]).
- execute
-
- respond_to do |format|
- if link_to_file
- format.json do
- render json: { link: link_to_file }
- end
- else
- format.json do
- render json: 'Invalid file.', status: :unprocessable_entity
- end
- end
- end
- end
-
- def show
- return render_404 if uploader.nil? || !uploader.file.exists?
-
- disposition = uploader.image_or_video? ? 'inline' : 'attachment'
- send_file uploader.file.path, disposition: disposition
- end
-
private
def uploader
@@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController
def image_or_video?
uploader && uploader.file.exists? && uploader.image_or_video?
end
+
+ def uploader_class
+ FileUploader
+ end
+
+ alias_method :model, :project
end
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
new file mode 100644
index 00000000000..3c4ddc1680d
--- /dev/null
+++ b/app/controllers/snippets/notes_controller.rb
@@ -0,0 +1,44 @@
+class Snippets::NotesController < ApplicationController
+ include NotesActions
+ include ToggleAwardEmoji
+
+ skip_before_action :authenticate_user!, only: [:index]
+ before_action :snippet
+ before_action :authorize_read_snippet!, only: [:show, :index, :create]
+
+ private
+
+ def note
+ @note ||= snippet.notes.find(params[:id])
+ end
+ alias_method :awardable, :note
+
+ def note_html(note)
+ render_to_string(
+ "shared/notes/_note",
+ layout: false,
+ formats: [:html],
+ locals: { note: note }
+ )
+ end
+
+ def project
+ nil
+ end
+
+ def snippet
+ PersonalSnippet.find_by(id: params[:snippet_id])
+ end
+
+ def note_params
+ super.merge(noteable_id: params[:snippet_id])
+ end
+
+ def finder_params
+ params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet')
+ end
+
+ def authorize_read_snippet!
+ return render_404 unless can?(current_user, :read_personal_snippet, snippet)
+ end
+end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 906833505d1..da1ae9a34d9 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -1,14 +1,15 @@
class SnippetsController < ApplicationController
+ include RendersNotes
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
include MarkdownPreview
include RendersBlob
- before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :download]
+ before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
# Allow read snippet
- before_action :authorize_read_snippet!, only: [:show, :raw, :download]
+ before_action :authorize_read_snippet!, only: [:show, :raw]
# Allow modify snippet
before_action :authorize_update_snippet!, only: [:edit, :update]
@@ -16,7 +17,7 @@ class SnippetsController < ApplicationController
# Allow destroy snippet
before_action :authorize_admin_snippet!, only: [:destroy]
- skip_before_action :authenticate_user!, only: [:index, :show, :raw, :download]
+ skip_before_action :authenticate_user!, only: [:index, :show, :raw]
layout 'snippets'
respond_to :html
@@ -64,6 +65,11 @@ class SnippetsController < ApplicationController
blob = @snippet.blob
override_max_blob_size(blob)
+ @noteable = @snippet
+
+ @discussions = @snippet.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+
respond_to do |format|
format.html do
render 'show'
@@ -83,14 +89,6 @@ class SnippetsController < ApplicationController
redirect_to snippets_path
end
- def download
- send_data(
- convert_line_endings(@snippet.content),
- type: 'text/plain; charset=utf-8',
- filename: @snippet.sanitized_file_name
- )
- end
-
def preview_markdown
render_markdown_preview(params[:text], skip_project_check: true)
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index f1bfd574f04..21a964fb391 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -1,50 +1,43 @@
class UploadsController < ApplicationController
- skip_before_action :authenticate_user!
- before_action :find_model, :authorize_access!
-
- def show
- uploader = @model.send(upload_mount)
-
- unless uploader.file_storage?
- return redirect_to uploader.url
- end
+ include UploadsActions
- unless uploader.file && uploader.file.exists?
- return render_404
- end
-
- disposition = uploader.image? ? 'inline' : 'attachment'
-
- expires_in 0.seconds, must_revalidate: true, private: true
- send_file uploader.file.path, disposition: disposition
- end
+ skip_before_action :authenticate_user!
+ before_action :find_model
+ before_action :authorize_access!, only: [:show]
+ before_action :authorize_create_access!, only: [:create]
private
def find_model
- unless upload_model && upload_mount
- return render_404
- end
+ return render_404 unless upload_model && upload_mount
@model = upload_model.find(params[:id])
end
def authorize_access!
authorized =
- case @model
- when Project
- can?(current_user, :read_project, @model)
- when Group
- can?(current_user, :read_group, @model)
+ case model
when Note
- can?(current_user, :read_project, @model.project)
- else
- # No authentication required for user avatars.
+ can?(current_user, :read_project, model.project)
+ when User
true
+ else
+ permission = "read_#{model.class.to_s.underscore}".to_sym
+
+ can?(current_user, permission, model)
end
- return if authorized
+ render_unauthorized unless authorized
+ end
+
+ def authorize_create_access!
+ # for now we support only personal snippets comments
+ authorized = can?(current_user, :comment_personal_snippet, model)
+ render_unauthorized unless authorized
+ end
+
+ def render_unauthorized
if current_user
render_404
else
@@ -58,17 +51,44 @@ class UploadsController < ApplicationController
"project" => Project,
"note" => Note,
"group" => Group,
- "appearance" => Appearance
+ "appearance" => Appearance,
+ "personal_snippet" => PersonalSnippet
}
upload_models[params[:model]]
end
def upload_mount
+ return true unless params[:mounted_as]
+
upload_mounts = %w(avatar attachment file logo header_logo)
if upload_mounts.include?(params[:mounted_as])
params[:mounted_as]
end
end
+
+ def uploader
+ return @uploader if defined?(@uploader)
+
+ if model.is_a?(PersonalSnippet)
+ @uploader = PersonalFileUploader.new(model, params[:secret])
+
+ @uploader.retrieve_from_store!(params[:filename])
+ else
+ @uploader = @model.send(upload_mount)
+
+ redirect_to @uploader.url unless @uploader.file_storage?
+ end
+
+ @uploader
+ end
+
+ def uploader_class
+ PersonalFileUploader
+ end
+
+ def model
+ @model ||= find_model
+ end
end
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 3c499184b41..dc6a8ad1f66 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -68,6 +68,8 @@ class NotesFinder
MergeRequestsFinder.new(@current_user, project_id: @project.id).execute
when "snippet", "project_snippet"
SnippetsFinder.new.execute(@current_user, filter: :by_project, project: @project)
+ when "personal_snippet"
+ PersonalSnippet.all
else
raise 'invalid target_type'
end
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index a9172f6767f..f187a3b61fe 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -1,29 +1,23 @@
class PipelinesFinder
- attr_reader :project, :pipelines
+ attr_reader :project, :pipelines, :params
- def initialize(project)
+ ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze
+
+ def initialize(project, params = {})
@project = project
@pipelines = project.pipelines
+ @params = params
end
- def execute(scope: nil)
- scoped_pipelines =
- case scope
- when 'running'
- pipelines.running
- when 'pending'
- pipelines.pending
- when 'finished'
- pipelines.finished
- when 'branches'
- from_ids(ids_for_ref(branches))
- when 'tags'
- from_ids(ids_for_ref(tags))
- else
- pipelines
- end
-
- scoped_pipelines.order(id: :desc)
+ def execute
+ items = pipelines
+ items = by_scope(items)
+ items = by_status(items)
+ items = by_ref(items)
+ items = by_name(items)
+ items = by_username(items)
+ items = by_yaml_errors(items)
+ sort_items(items)
end
private
@@ -43,4 +37,78 @@ class PipelinesFinder
def tags
project.repository.tag_names
end
+
+ def by_scope(items)
+ case params[:scope]
+ when 'running'
+ items.running
+ when 'pending'
+ items.pending
+ when 'finished'
+ items.finished
+ when 'branches'
+ from_ids(ids_for_ref(branches))
+ when 'tags'
+ from_ids(ids_for_ref(tags))
+ else
+ items
+ end
+ end
+
+ def by_status(items)
+ return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
+
+ items.where(status: params[:status])
+ end
+
+ def by_ref(items)
+ if params[:ref].present?
+ items.where(ref: params[:ref])
+ else
+ items
+ end
+ end
+
+ def by_name(items)
+ if params[:name].present?
+ items.joins(:user).where(users: { name: params[:name] })
+ else
+ items
+ end
+ end
+
+ def by_username(items)
+ if params[:username].present?
+ items.joins(:user).where(users: { username: params[:username] })
+ else
+ items
+ end
+ end
+
+ def by_yaml_errors(items)
+ case Gitlab::Utils.to_boolean(params[:yaml_errors])
+ when true
+ items.where("yaml_errors IS NOT NULL")
+ when false
+ items.where("yaml_errors IS NULL")
+ else
+ items
+ end
+ end
+
+ def sort_items(items)
+ order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by])
+ params[:order_by]
+ else
+ :id
+ end
+
+ sort = if params[:sort] =~ /\A(ASC|DESC)\z/i
+ params[:sort]
+ else
+ :desc
+ end
+
+ items.order(order_by => sort)
+ end
end
diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb
index 167b09e678f..024cf38469e 100644
--- a/app/helpers/award_emoji_helper.rb
+++ b/app/helpers/award_emoji_helper.rb
@@ -1,10 +1,14 @@
module AwardEmojiHelper
def toggle_award_url(awardable)
- return url_for([:toggle_award_emoji, awardable]) unless @project
+ return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note)
if awardable.is_a?(Note)
# We render a list of notes very frequently and calling the specific method is a lot faster than the generic one (4.5x)
- toggle_award_emoji_namespace_project_note_url(@project.namespace, @project, awardable.id)
+ if awardable.for_personal_snippet?
+ toggle_award_emoji_snippet_note_path(awardable.noteable, awardable)
+ else
+ toggle_award_emoji_namespace_project_note_path(@project.namespace, @project, awardable.id)
+ end
else
url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable])
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 377b080b3c6..af430270ae4 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -52,7 +52,7 @@ module BlobHelper
if !on_top_of_branch?(project, ref)
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' }
- elsif blob.valid_lfs_pointer?
+ elsif blob.stored_externally?
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' }
elsif can_modify_blob?(blob, project, ref)
button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
@@ -95,7 +95,7 @@ module BlobHelper
end
def can_modify_blob?(blob, project = @project, ref = @ref)
- !blob.valid_lfs_pointer? && can_edit_tree?(project, ref)
+ !blob.stored_externally? && can_edit_tree?(project, ref)
end
def leave_edit_message
@@ -119,7 +119,9 @@ module BlobHelper
end
def blob_raw_url
- if @snippet
+ if @build && @entry
+ raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
+ elsif @snippet
if @snippet.project_id
raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
else
@@ -223,7 +225,9 @@ module BlobHelper
end
def open_raw_blob_button(blob)
- if blob.raw_binary?
+ return if blob.empty?
+
+ if blob.raw_binary? || blob.stored_externally?
icon = icon('download')
title = 'Download'
else
@@ -244,19 +248,29 @@ module BlobHelper
viewer.max_size
end
"it is larger than #{number_to_human_size(max_size)}"
- when :server_side_but_stored_in_lfs
- "it is stored in LFS"
+ when :server_side_but_stored_externally
+ case viewer.blob.external_storage
+ when :lfs
+ 'it is stored in LFS'
+ when :build_artifact
+ 'it is stored as a job artifact'
+ else
+ 'it is stored externally'
+ end
end
end
def blob_render_error_options(viewer)
+ error = viewer.render_error
options = []
- if viewer.render_error == :too_large && viewer.can_override_max_size?
+ if error == :too_large && viewer.can_override_max_size?
options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil)))
end
- if viewer.rich? && viewer.blob.rendered_as_text?
+ # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error,
+ # so don't bother switching.
+ if viewer.rich? && viewer.blob.rendered_as_text? && error != :server_side_but_stored_externally
options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' })
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index e9b7cbbad6a..1336c676134 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -208,6 +208,8 @@ module GitlabRoutingHelper
browse_namespace_project_build_artifacts_path(*args)
when 'file'
file_namespace_project_build_artifacts_path(*args)
+ when 'raw'
+ raw_namespace_project_build_artifacts_path(*args)
end
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index e347f61fb8d..2614cdfe90e 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -1,6 +1,6 @@
module MergeRequestsHelper
def new_mr_path_from_push_event(event)
- target_project = event.project.forked_from_project || event.project
+ target_project = event.project.default_merge_request_target
new_namespace_project_merge_request_path(
event.project.namespace,
event.project,
@@ -127,6 +127,10 @@ module MergeRequestsHelper
end
end
+ def target_projects(project)
+ [project, project.default_merge_request_target].uniq
+ end
+
def merge_request_button_visibility(merge_request, closed)
return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork?
end
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index c9e70faa52e..c515774140c 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -115,4 +115,28 @@ module MilestonesHelper
end
end
end
+
+ def milestone_merge_request_tab_path(milestone)
+ if @project
+ merge_requests_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
+ elsif @group
+ merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ end
+ end
+
+ def milestone_participants_tab_path(milestone)
+ if @project
+ participants_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
+ elsif @group
+ participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ end
+ end
+
+ def milestone_labels_tab_path(milestone)
+ if @project
+ labels_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
+ elsif @group
+ labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ end
+ end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index eab0738a368..08180883eb9 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -60,20 +60,16 @@ module NotesHelper
note.project.team.human_max_access(note.author_id)
end
- def discussion_diff_path(discussion)
- if discussion.for_merge_request? && discussion.diff_discussion?
- if discussion.active?
- # Without a diff ID, the link always points to the latest diff version
- diff_id = nil
- elsif merge_request_diff = discussion.latest_merge_request_diff
- diff_id = merge_request_diff.id
- else
- # If the discussion is not active, and we cannot find the latest
- # merge request diff for this discussion, we return no path at all.
- return
- end
-
- diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code)
+ def discussion_path(discussion)
+ if discussion.for_merge_request?
+ return unless discussion.diff_discussion?
+
+ version_params = discussion.merge_request_version_params
+ return unless version_params
+
+ path_params = version_params.merge(anchor: discussion.line_code)
+
+ diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, path_params)
elsif discussion.for_commit?
anchor = discussion.line_code if discussion.diff_discussion?
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 979264c9421..2fd64b3441e 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -8,6 +8,14 @@ module SnippetsHelper
end
end
+ def download_snippet_path(snippet)
+ if snippet.project_id
+ raw_namespace_project_snippet_path(@project.namespace, @project, snippet, inline: false)
+ else
+ raw_snippet_path(snippet, inline: false)
+ end
+ end
+
# Return the path of a snippets index for a user or for a project
#
# @returns String, path to snippet index
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 2fda98cae90..4882d9b71d2 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -70,6 +70,14 @@ module SortingHelper
}
end
+ def tags_sort_options_hash
+ {
+ sort_value_name => sort_title_name,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_oldest_updated => sort_title_oldest_updated
+ }
+ end
+
def sort_title_priority
'Priority'
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index f7b5a5f4dfc..a91e3da309c 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -76,7 +76,7 @@ module TreeHelper
"A new branch will be created in your fork and a new merge request will be started."
end
- def tree_breadcrumbs(tree, max_links = 2)
+ def path_breadcrumbs(max_links = 6)
if @path.present?
part_path = ""
parts = @path.split('/')
@@ -88,7 +88,7 @@ module TreeHelper
part_path = part if part_path.empty?
next if parts.count > max_links && !parts.last(2).include?(part)
- yield(part, tree_join(@ref, part_path))
+ yield(part, part_path)
end
end
end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 1cdb8811cff..a4fae22a0c4 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -28,7 +28,7 @@ class Blob < SimpleDelegator
BlobViewer::Sketch,
BlobViewer::Video,
-
+
BlobViewer::PDF,
BlobViewer::BinarySTL,
@@ -75,19 +75,37 @@ class Blob < SimpleDelegator
end
def no_highlighting?
- size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE
+ raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE
+ end
+
+ def empty?
+ raw_size == 0
end
def too_large?
size && truncated?
end
+ def external_storage_error?
+ if external_storage == :lfs
+ !project&.lfs_enabled?
+ else
+ false
+ end
+ end
+
+ def stored_externally?
+ return @stored_externally if defined?(@stored_externally)
+
+ @stored_externally = external_storage && !external_storage_error?
+ end
+
# Returns the size of the file that this blob represents. If this blob is an
# LFS pointer, this is the size of the file stored in LFS. Otherwise, this is
# the size of the blob itself.
def raw_size
- if valid_lfs_pointer?
- lfs_size
+ if stored_externally?
+ external_size
else
size
end
@@ -98,9 +116,13 @@ class Blob < SimpleDelegator
# text-based rich blob viewer matched on the file's extension. Otherwise, this
# depends on the type of the blob itself.
def raw_binary?
- if valid_lfs_pointer?
+ if stored_externally?
if rich_viewer
rich_viewer.binary?
+ elsif Linguist::Language.find_by_filename(name).any?
+ false
+ elsif _mime_type
+ _mime_type.binary?
else
true
end
@@ -118,15 +140,7 @@ class Blob < SimpleDelegator
end
def readable_text?
- text? && !valid_lfs_pointer? && !too_large?
- end
-
- def valid_lfs_pointer?
- lfs_pointer? && project&.lfs_enabled?
- end
-
- def invalid_lfs_pointer?
- lfs_pointer? && !project&.lfs_enabled?
+ text? && !stored_externally? && !too_large?
end
def simple_viewer
@@ -165,10 +179,10 @@ class Blob < SimpleDelegator
end
def rich_viewer_class
- return if invalid_lfs_pointer? || empty?
+ return if empty? || external_storage_error?
classes =
- if valid_lfs_pointer?
+ if stored_externally?
BINARY_VIEWERS + TEXT_VIEWERS
elsif binary?
BINARY_VIEWERS
diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb
index f944b00c9d3..a8b91d8d6bc 100644
--- a/app/models/blob_viewer/base.rb
+++ b/app/models/blob_viewer/base.rb
@@ -70,12 +70,13 @@ module BlobViewer
return @render_error if defined?(@render_error)
@render_error =
- if server_side_but_stored_in_lfs?
- # Files stored in LFS can only be rendered using a client-side viewer,
+ if server_side_but_stored_externally?
+ # Files that are not stored in the repository, like LFS files and
+ # build artifacts, can only be rendered using a client-side viewer,
# since we do not want to read large amounts of data into memory on the
# server side. Client-side viewers use JS and can fetch the file from
# `blob_raw_url` using AJAX.
- :server_side_but_stored_in_lfs
+ :server_side_but_stored_externally
elsif override_max_size ? absolutely_too_large? : too_large?
:too_large
end
@@ -89,8 +90,8 @@ module BlobViewer
private
- def server_side_but_stored_in_lfs?
- server_side? && blob.valid_lfs_pointer?
+ def server_side_but_stored_externally?
+ server_side? && blob.stored_externally?
end
end
end
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
new file mode 100644
index 00000000000..b35febc9ac5
--- /dev/null
+++ b/app/models/ci/artifact_blob.rb
@@ -0,0 +1,35 @@
+module Ci
+ class ArtifactBlob
+ include BlobLike
+
+ attr_reader :entry
+
+ def initialize(entry)
+ @entry = entry
+ end
+
+ delegate :name, :path, to: :entry
+
+ def id
+ Digest::SHA1.hexdigest(path)
+ end
+
+ def size
+ entry.metadata[:size]
+ end
+
+ def data
+ "Build artifact #{path}"
+ end
+
+ def mode
+ entry.metadata[:mode]
+ end
+
+ def external_storage
+ :build_artifact
+ end
+
+ alias_method :external_size, :size
+ end
+end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index bb4cb8efd15..88a015cdb77 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -236,8 +236,8 @@ class Commit
project.pipelines.where(sha: sha)
end
- def latest_pipeline
- pipelines.last
+ def last_pipeline
+ @last_pipeline ||= pipelines.last
end
def status(ref = nil)
diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb
new file mode 100644
index 00000000000..adb81561000
--- /dev/null
+++ b/app/models/concerns/blob_like.rb
@@ -0,0 +1,48 @@
+module BlobLike
+ extend ActiveSupport::Concern
+ include Linguist::BlobHelper
+
+ def id
+ raise NotImplementedError
+ end
+
+ def name
+ raise NotImplementedError
+ end
+
+ def path
+ raise NotImplementedError
+ end
+
+ def size
+ 0
+ end
+
+ def data
+ nil
+ end
+
+ def mode
+ nil
+ end
+
+ def binary?
+ false
+ end
+
+ def load_all_data!(repository)
+ # No-op
+ end
+
+ def truncated?
+ false
+ end
+
+ def external_storage
+ nil
+ end
+
+ def external_size
+ nil
+ end
+end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index f033028c4e5..eb32bf3d32a 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -78,6 +78,9 @@ module CacheMarkdownField
def cached_html_up_to_date?(markdown_field)
html_field = cached_markdown_fields.html_field(markdown_field)
+ cached = !cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil?
+ return false unless cached
+
markdown_changed = attribute_changed?(markdown_field) || false
html_changed = attribute_changed?(html_field) || false
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index 8ee42875670..a7bdf5587b2 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -11,6 +11,7 @@ module DiscussionOnDiff
:diff_line,
:for_line?,
:active?,
+ :created_at_diff?,
to: :first_note
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index 6c27dd5aa5c..6359f7596b1 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -30,6 +30,10 @@ module NoteOnDiff
raise NotImplementedError
end
+ def created_at_diff?(diff_refs)
+ false
+ end
+
private
def noteable_diff_refs
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index 6a6466b493b..d627fbe327f 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -10,7 +10,6 @@ class DiffDiscussion < Discussion
delegate :position,
:original_position,
- :latest_merge_request_diff,
to: :first_note
@@ -18,6 +17,25 @@ class DiffDiscussion < Discussion
false
end
+ def merge_request_version_params
+ return unless for_merge_request?
+
+ if active?
+ {}
+ else
+ diff_refs = position.diff_refs
+
+ if diff = noteable.merge_request_diff_for(diff_refs)
+ { diff_id: diff.id }
+ elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha)
+ {
+ diff_id: diff.id,
+ start_sha: diff_refs.start_sha
+ }
+ end
+ end
+ end
+
def reply_attributes
super.merge(
original_position: original_position.to_json,
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index abe4518d62a..76c59199afd 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -65,10 +65,11 @@ class DiffNote < Note
self.position.diff_refs == diff_refs
end
- def latest_merge_request_diff
- return unless for_merge_request?
+ def created_at_diff?(diff_refs)
+ return false unless supported?
+ return true if for_commit?
- self.noteable.merge_request_diff_for(self.position.diff_refs)
+ self.original_position.diff_refs == diff_refs
end
private
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 305fc01f041..78bde6820da 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -143,6 +143,14 @@ class Issue < ActiveRecord::Base
branches_with_iid - branches_with_merge_request
end
+ # Returns boolean if a related branch exists for the current issue
+ # ignores merge requests branchs
+ def has_related_branch?
+ project.repository.branch_names.any? do |branch|
+ /\A#{iid}-(?!\d+-stable)/i =~ branch
+ end
+ end
+
# To allow polymorphism with MergeRequest.
def source_project
project
diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb
index e617ce36f56..3c1d34db5fa 100644
--- a/app/models/legacy_diff_discussion.rb
+++ b/app/models/legacy_diff_discussion.rb
@@ -9,14 +9,14 @@ class LegacyDiffDiscussion < Discussion
memoized_values << :active
- def legacy_diff_discussion?
- true
- end
-
def self.note_class
LegacyDiffNote
end
+ def legacy_diff_discussion?
+ true
+ end
+
def active?(*args)
return @active if @active.present?
@@ -27,6 +27,16 @@ class LegacyDiffDiscussion < Discussion
!active?
end
+ def merge_request_version_params
+ return unless for_merge_request?
+
+ if active?
+ {}
+ else
+ nil
+ end
+ end
+
def reply_attributes
super.merge(line_code: line_code)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 9d2288c311e..12c5481cd6d 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -100,6 +100,7 @@ class MergeRequest < ActiveRecord::Base
validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing?
validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?]
validate :validate_fork, unless: :closed_without_fork?
+ validate :validate_target_project, on: :create
scope :by_source_or_target_branch, ->(branch_name) do
where("source_branch = :branch OR target_branch = :branch", branch: branch_name)
@@ -330,6 +331,12 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def validate_target_project
+ return true if target_project.merge_requests_enabled?
+
+ errors.add :base, 'Target project has disabled merge requests'
+ end
+
def validate_fork
return true unless target_project && source_project
return true if target_project == source_project
@@ -367,12 +374,18 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff(true)
end
- def merge_request_diff_for(diff_refs)
- @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs|
- h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs)
+ def merge_request_diff_for(diff_refs_or_sha)
+ @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha|
+ diffs = merge_request_diffs.viewable.select_without_diff
+ h[diff_refs_or_sha] =
+ if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs)
+ diffs.find_by_diff_refs(diff_refs_or_sha)
+ else
+ diffs.find_by(head_commit_sha: diff_refs_or_sha)
+ end
end
- @merge_request_diffs_by_diff_refs[diff_refs]
+ @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha]
end
def reload_diff_if_branch_changed
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9bfa731785f..397dc7a25ab 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -33,7 +33,7 @@ class Namespace < ActiveRecord::Base
validates :path,
presence: true,
length: { maximum: 255 },
- namespace: true
+ dynamic_path: true
validate :nesting_level_allowed
@@ -220,6 +220,10 @@ class Namespace < ActiveRecord::Base
Project.inside_path(full_path)
end
+ def has_parent?
+ parent.present?
+ end
+
private
def repository_storage_paths
diff --git a/app/models/note.rb b/app/models/note.rb
index e720bfba030..b06985b4a6f 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -115,11 +115,19 @@ class Note < ActiveRecord::Base
end
def grouped_diff_discussions(diff_refs = nil)
- diff_notes.
- fresh.
- discussions.
- select { |n| n.active?(diff_refs) }.
- group_by(&:line_code)
+ groups = {}
+
+ diff_notes.fresh.discussions.each do |discussion|
+ if discussion.active?(diff_refs)
+ discussions = groups[discussion.line_code] ||= []
+ elsif diff_refs && discussion.created_at_diff?(diff_refs)
+ discussions = groups[discussion.original_line_code] ||= []
+ end
+
+ discussions << discussion if discussions
+ end
+
+ groups
end
def count_for_collection(ids, type)
@@ -141,10 +149,6 @@ class Note < ActiveRecord::Base
true
end
- def latest_merge_request_diff
- nil
- end
-
def max_attachment_size
current_application_settings.max_attachment_size.megabytes.to_i
end
diff --git a/app/models/project.rb b/app/models/project.rb
index c7dc562c238..025db89ebfd 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -196,13 +196,14 @@ class Project < ActiveRecord::Base
message: Gitlab::Regex.project_name_regex_message }
validates :path,
presence: true,
- project_path: true,
+ dynamic_path: true,
length: { maximum: 255 },
format: { with: Gitlab::Regex.project_path_regex,
- message: Gitlab::Regex.project_path_regex_message }
+ message: Gitlab::Regex.project_path_regex_message },
+ uniqueness: { scope: :namespace_id }
+
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
- validates :path, uniqueness: { scope: :namespace_id }
validates :import_url, addressable_url: true, if: :external_import?
validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
@@ -1270,6 +1271,9 @@ class Project < ActiveRecord::Base
else
update_attribute(name, value)
end
+
+ rescue ActiveRecord::RecordNotSaved => e
+ handle_update_attribute_error(e, value)
end
def pushes_since_gc
@@ -1314,6 +1318,14 @@ class Project < ActiveRecord::Base
namespace_id_changed?
end
+ def default_merge_request_target
+ if forked_from_project&.merge_requests_enabled?
+ forked_from_project
+ else
+ self
+ end
+ end
+
alias_method :name_with_namespace, :full_name
alias_method :human_name, :full_name
alias_method :path_with_namespace, :full_path
@@ -1383,4 +1395,16 @@ class Project < ActiveRecord::Base
ContainerRepository.build_root_repository(self).has_tags?
end
+
+ def handle_update_attribute_error(ex, value)
+ if ex.message.start_with?('Failed to replace')
+ if value.respond_to?(:each)
+ invalid = value.detect(&:invalid?)
+
+ raise ex, ([ex.message] + invalid.errors.full_messages).join(' ') if invalid
+ end
+ end
+
+ raise ex
+ end
end
diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb
index 7621a5fa2d8..e2ad586aea7 100644
--- a/app/models/project_services/chat_message/base_message.rb
+++ b/app/models/project_services/chat_message/base_message.rb
@@ -50,5 +50,16 @@ module ChatMessage
def link(text, url)
"[#{text}](#{url})"
end
+
+ def pretty_duration(seconds)
+ parse_string =
+ if duration < 1.hour
+ '%M:%S'
+ else
+ '%H:%M:%S'
+ end
+
+ Time.at(seconds).utc.strftime(parse_string)
+ end
end
end
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 4628d9b1a7b..47b68f00cff 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -15,7 +15,7 @@ module ChatMessage
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status]
- @duration = pipeline_attributes[:duration]
+ @duration = pipeline_attributes[:duration].to_i
@pipeline_id = pipeline_attributes[:id]
end
@@ -37,7 +37,7 @@ module ChatMessage
{
title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}",
subtitle: "in #{project_link}",
- text: "in #{duration} #{time_measure}",
+ text: "in #{pretty_duration(duration)}",
image: user_avatar || ''
}
end
@@ -45,7 +45,7 @@ module ChatMessage
private
def message
- "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}"
+ "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}"
end
def humanized_status
@@ -84,9 +84,5 @@ module ChatMessage
def pipeline_link
"[##{pipeline_id}](#{pipeline_url})"
end
-
- def time_measure
- 'second'.pluralize(duration)
- end
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index feabfa111fb..0c797dd5814 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -505,14 +505,8 @@ class Repository
delegate :tag_names, to: :raw_repository
cache_method :tag_names, fallback: []
- def branch_count
- branches.size
- end
+ delegate :branch_count, :tag_count, to: :raw_repository
cache_method :branch_count, fallback: 0
-
- def tag_count
- raw_repository.rugged.tags.count
- end
cache_method :tag_count, fallback: 0
def avatar
@@ -795,7 +789,7 @@ class Repository
}
options.merge!(get_committer_and_author(user, email: author_email, name: author_name))
- Rugged::Commit.create(rugged, options)
+ create_commit(options)
end
end
# rubocop:enable Metrics/ParameterLists
@@ -842,7 +836,7 @@ class Repository
tree: merge_index.write_tree(rugged),
)
- commit_id = Rugged::Commit.create(rugged, actual_options)
+ commit_id = create_commit(actual_options)
merge_request.update(in_progress_merge_commit_sha: commit_id)
commit_id
end
@@ -865,12 +859,11 @@ class Repository
committer = user_to_committer(user)
- Rugged::Commit.create(rugged,
- message: commit.revert_message(user),
- author: committer,
- committer: committer,
- tree: revert_tree_id,
- parents: [start_commit.sha])
+ create_commit(message: commit.revert_message(user),
+ author: committer,
+ committer: committer,
+ tree: revert_tree_id,
+ parents: [start_commit.sha])
end
end
@@ -889,16 +882,15 @@ class Repository
committer = user_to_committer(user)
- Rugged::Commit.create(rugged,
- message: commit.message,
- author: {
- email: commit.author_email,
- name: commit.author_name,
- time: commit.authored_date
- },
- committer: committer,
- tree: cherry_pick_tree_id,
- parents: [start_commit.sha])
+ create_commit(message: commit.message,
+ author: {
+ email: commit.author_email,
+ name: commit.author_name,
+ time: commit.authored_date
+ },
+ committer: committer,
+ tree: cherry_pick_tree_id,
+ parents: [start_commit.sha])
end
end
@@ -906,7 +898,7 @@ class Repository
GitOperationService.new(user, self).with_branch(branch_name) do
committer = user_to_committer(user)
- Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
+ create_commit(params.merge(author: committer, committer: committer))
end
end
@@ -1148,6 +1140,12 @@ class Repository
Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags))
end
+ def create_commit(params = {})
+ params[:message].delete!("\r")
+
+ Rugged::Commit.create(rugged, params)
+ end
+
def repository_storage_path
@project.repository_storage_path
end
diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb
index d6cab74eb1a..fa5fa151607 100644
--- a/app/models/snippet_blob.rb
+++ b/app/models/snippet_blob.rb
@@ -1,5 +1,5 @@
class SnippetBlob
- include Linguist::BlobHelper
+ include BlobLike
attr_reader :snippet
@@ -28,32 +28,4 @@ class SnippetBlob
Banzai.render_field(snippet, :content)
end
-
- def mode
- nil
- end
-
- def binary?
- false
- end
-
- def load_all_data!(repository)
- # No-op
- end
-
- def lfs_pointer?
- false
- end
-
- def lfs_oid
- nil
- end
-
- def lfs_size
- nil
- end
-
- def truncated?
- false
- end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index bd9c9f99663..2b7ebe6c1a7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -118,7 +118,7 @@ class User < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
validates :username,
- namespace: true,
+ dynamic_path: true,
presence: true,
uniqueness: { case_sensitive: false }
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index d3913986cd8..e1e5336da8c 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy
can! :read_personal_snippet if @subject.public?
return unless @user
+ if @subject.public?
+ can! :comment_personal_snippet
+ end
+
if @subject.author == @user
can! :read_personal_snippet
can! :update_personal_snippet
can! :destroy_personal_snippet
can! :admin_personal_snippet
+ can! :comment_personal_snippet
end
unless @user.external?
@@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy
if @subject.internal? && !@user.external?
can! :read_personal_snippet
+ can! :comment_personal_snippet
end
end
end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 86ac513b3c0..070b0c35e36 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -48,6 +48,17 @@ module Projects
available_public_keys.any?
end
+ def as_json
+ serializer = DeployKeySerializer.new
+ opts = { user: current_user }
+
+ {
+ enabled_keys: serializer.represent(enabled_keys, opts),
+ available_project_keys: serializer.represent(available_project_keys, opts),
+ public_keys: serializer.represent(available_public_keys, opts)
+ }
+ end
+
def to_partial_path
'projects/deploy_keys/index'
end
diff --git a/app/serializers/README.md b/app/serializers/README.md
new file mode 100644
index 00000000000..0337f88db5f
--- /dev/null
+++ b/app/serializers/README.md
@@ -0,0 +1,325 @@
+# Serializers
+
+This is a documentation for classes located in `app/serializers` directory.
+
+In GitLab, we use [grape-entities][grape-entity-project], accompanied by a
+serializer, to convert a Ruby object to its JSON representation.
+
+Serializers are typically used in controllers to build a JSON response
+that is usually consumed by a frontend code.
+
+## Why using a serializer is important?
+
+Using serializers, instead of `to_json` method, has several benefits:
+
+* it helps to prevent exposure of a sensitive data stored in the database
+* it makes it easier to test what should and should not be exposed
+* it makes it easier to reuse serialization entities that are building blocks
+* it makes it easier to move complexity from controllers to easily testable
+ classes
+* it encourages hiding complexity behind intentions-revealing interfaces
+* it makes it easier to take care about serialization performance concerns
+* it makes it easier to reduce merge conflicts between CE -> EE
+* it makes it easier to benefit from domain driven development techniques
+
+## What is a serializer?
+
+A serializer is a class that encapsulates all business rules for building a
+JSON response using serialization entities.
+
+It is designed to be testable and to support passing additional context from
+the controller.
+
+## What is a serialization entity?
+
+Entities are lightweight structures that allow to represent domain models
+in a consistent and abstracted way, and reuse them as building blocks to
+create a payload.
+
+Entities located in `app/serializers` are usually derived from a
+[`Grape::Entity`][grape-entity-class] class.
+
+Serialization entities that do require to have a knowledge about specific
+elements of the request, need to mix `RequestAwareEntity` in.
+
+A serialization entity usually maps a domain model class into its JSON
+representation. It rarely happens that a serialization entity exists without
+a corresponding domain model class. As an example, we have an `Issue` class and
+a corresponding `IssueSerializer`.
+
+Serialization entites are designed to reuse other serialization entities, which
+is a convenient way to create a multi-level JSON representation of a piece of
+a domain model you want to serialize.
+
+See [documentation for Grape Entites][grape-entity-readme] for more details.
+
+## How to implement a serializer?
+
+### Base implementation
+
+In order to effectively implement a serializer it is necessary to create a new
+class in `app/serializers`. See existing serializers as an example.
+
+A new serializer should inherit from a `BaseSerializer` class. It is necessary
+to specify which serialization entity will be used to serialize a resource.
+
+```ruby
+class MyResourceSerializer < BaseSerialize
+ entity MyResourceEntity
+end
+```
+
+The example above shows how a most simple serializer can look like.
+
+Given that the entity `MyResourceEntity` exists, you can now use
+`MyResourceSerializer` in the controller by creating an instance of it, and
+calling `MyResourceSerializer#represent(resource)` method.
+
+Note that a `resource` can be either a single object, an array of objects or an
+`ActiveRecord::Relation` object. A serialization entity should be smart enough
+to accurately represent each of these.
+
+It should not be necessary to use `Enumerable#map`, and it should be avoided
+from the performance reasons.
+
+### Choosing what gets serialized
+
+It often happens that you might want to use the same serializer in many places,
+but sometimes the intention is to only expose a small subset of object's
+attributes in one place, and a different subset in another.
+
+`BaseSerializer#represent(resource, opts = {})` method can take an additional
+hash argument, `opts`, that defines what is going to be serialized.
+
+`BaseSerializer` will pass these options to a serialization entity. See
+how it is [documented in the upstream project][grape-entity-only].
+
+With this approach you can extend the serializer to respond to methods that will
+create a JSON response according to your needs.
+
+```ruby
+class PipelineSerializer < BaseSerializer
+ entity PipelineEntity
+
+ def represent_details(resource)
+ represent(resource, only: [:details])
+ end
+
+ def represent_status(resource)
+ represent(resource, only: [:status])
+ end
+end
+```
+
+It is possible to use `only` and `except` keywords. Both keywords do support
+nested attributes, like `except: [:id, { user: [:id] }]`.
+
+Passing `only` and `except` to the `represent` method from a controller is
+possible, but it defies principles of encapsulation and testability, and it is
+better to avoid it, and to add a specific method to the serializer instead.
+
+### Reusing serialization entities from the API
+
+Public API in GitLab is implemented using [Grape][grape-project].
+
+Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes.
+This means that it is possible to reuse these classes to implement internal
+serializers.
+
+You can either use such entity directly:
+
+```ruby
+class MyResourceSerializer < BaseSerializer
+ entity API::Entities::SomeEntity
+end
+```
+
+Or derive a new serialization entity class from it:
+
+```ruby
+class MyEntity < API::Entities::SomeEntity
+ include RequestAwareEntity
+
+ unexpose :something
+end
+```
+
+It might be a good idea to write specs for entities that do inherit from
+the API, because when API payloads are changed / extended, it is easy to forget
+about the impact on the internal API through a serializer that reuses API
+entities.
+
+It is usually safe to do that, because API entities rarely break backward
+compatibility, but additional exposure may have a performance impact when API
+gets extended significantly. Write tests that check if only necessary data is
+exposed.
+
+## How to write tests for a serializer?
+
+Like every other class in the project, creating a serializer warrants writing
+tests for it.
+
+It is usually a good idea to test each public method in the serializer against
+a valid payload. `BaseSerializer#represent` returns a hash, so it is possible
+to use usual RSpec matchers like `include`.
+
+Sometimes, when the payload is large, it makes sense to validate it entirely
+using `match_response_schema` matcher along with a new fixture that can be
+stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema`
+gem, which is quite flexible, see a [documentation][json-schema-gem] for it.
+
+## How to use a serializer in a controller?
+
+Once a new serializer is implemented, it is possible to use it in a controller.
+
+Create an instance of the serializer and render the response.
+
+```ruby
+def index
+ format.json do
+ render json: MyResourceSerializer
+ .new(current_user: @current_user)
+ .represent_details(@project.resources)
+ nd
+end
+```
+
+If it is necessary to include additional information in the payload, it is
+possible to extend what is going to be rendered, the usual way:
+
+```ruby
+def index
+ format.json do
+ render json: {
+ resources: MyResourceSerializer
+ .new(current_user: @current_user)
+ .represent_details(@project.resources),
+ count: @project.resources.count
+ }
+ nd
+end
+```
+
+Note that in these examples an additional context is being passed to the
+serializer (`current_user: @current_user`).
+
+## How to pass an additional context from the controller?
+
+It is possible to pass an additional context from a controller to a
+serializer and each serialization entity that is used in the process.
+
+Serialization entities that do require an additional context have
+`RequestAwareEntity` concern mixed in. This piece of the code exposes a method
+called `request` in every serialization entity that is instantiated during
+serialization.
+
+An object returned by this method is an instance of `EntityRequest`, which
+behaves like an `OpenStruct` object, with the difference that it will raise
+an error if an unknown method is called.
+
+In other words, in the previous example, `request` method will return an
+instance of `EntityRequest` that responds to `current_user` method. It will be
+available in every serialization entity instantiated by `MyResourceSerializer`.
+
+`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be
+refactored soon. Please avoid passing an additional context that is not
+required by a serialization entity.
+
+At the moment, the context that is passed to entities most often is
+`current_user` and `project`.
+
+## How is this related to using presenters?
+
+Payload created by a serializer is usually a representation of the backed code,
+combined with the current request data. Therefore, technically, serializers
+are presenters that create payload consumed by a frontend code, usually Vue
+components.
+
+In GitLab, it is possible to use [presenters][presenters-readme], but
+`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898].
+
+It is possible to use presenters when serializer is used to represent only
+a single object. It is not supported when `ActiveRecord::Relation` is being
+serialized.
+
+```ruby
+MyObjectSerializer.new.represent(object.present)
+```
+
+## Best practices
+
+1. Do not invoke a serializer from within a serialization entity.
+
+ If you need to use a serializer from within a serialization entity, it is
+ possible that you are missing a class for an important domain concept.
+
+ Consider creating a new domain class and a corresponding serialization
+ entity for it.
+
+1. Use only one approach to switch behavior of the serializer.
+
+ It is possible to use a few approaches to switch a behavior of the
+ serializer. Most common are using a [Fluent Interface][fluent-interface]
+ and creating a separate `represent_something` methods.
+
+ Whatever you choose, it might be better to use only one approach at a time.
+
+1. Do not forget about creating specs for serialization entities.
+
+ Writing tests for the serializer indeed does cover testing a behavior of
+ serialization entities that the serializer instantiates. However it might
+ be a good idea to write separate tests for entities as well, because these
+ are meant to be reused in different serializers, and a serializer can
+ change a behavior of a serialization entity.
+
+1. Use `ActiveRecord::Relation` where possible
+
+ Using an `ActiveRecord::Relation` might help from the performance perspective.
+
+1. Be diligent about passing an additional context from the controller.
+
+ Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack
+ of high-level mechanism. It is meant to be refactored, and current
+ implementation is error prone. Imagine the situation that one serialization
+ entity requires `request.user` attribute, but the second one wants
+ `request.current_user`. When it happens that these two entities are used in
+ the same serialization request, you might need to pass both parameters to
+ the serializer, which is obviously not a perfect situation.
+
+ When in doubt, pass only `current_user` and `project` if these are required.
+
+1. Keep performance concerns in mind
+
+ Using a serializer incorrectly can have significant impact on the
+ performance.
+
+ Because serializers are technically presenters, it is often necessary
+ to calculate, for example, paths to various controller-actions.
+ Since using URL helpers usually involve passing `project` and `namespace`
+ adding `includes(project: :namespace)` in the serializer, can help to avoid
+ N+1 queries.
+
+ Also, try to avoid using `Enumerable#map` or other methods that will
+ execute a database query eagerly.
+
+1. Avoid passing `only` and `except` from the controller.
+1. Write tests checking for N+1 queries.
+1. Write controller tests for actions / formats using serializers.
+1. Write tests that check if only necessary data is exposed.
+1. Write tests that check if no sensitive data is exposed.
+
+## Future
+
+* [Next iteration of serializers][issue-27569]
+
+[grape-project]: http://www.ruby-grape.org
+[grape-entity-project]: https://github.com/ruby-grape/grape-entity
+[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md
+[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb
+[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want
+[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md
+[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface
+[json-schema-gem]: https://github.com/ruby-json-schema/json-schema
+[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045
+[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898
+[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
new file mode 100644
index 00000000000..d75a83d0fa5
--- /dev/null
+++ b/app/serializers/deploy_key_entity.rb
@@ -0,0 +1,14 @@
+class DeployKeyEntity < Grape::Entity
+ expose :id
+ expose :user_id
+ expose :title
+ expose :fingerprint
+ expose :can_push
+ expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
+ expose :almost_orphaned?, as: :almost_orphaned
+ expose :created_at
+ expose :updated_at
+ expose :projects, using: ProjectEntity do |deploy_key|
+ deploy_key.projects.select { |project| options[:user].can?(:read_project, project) }
+ end
+end
diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb
new file mode 100644
index 00000000000..8f849eb88b7
--- /dev/null
+++ b/app/serializers/deploy_key_serializer.rb
@@ -0,0 +1,3 @@
+class DeployKeySerializer < BaseSerializer
+ entity DeployKeyEntity
+end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index d610fbe0c8a..8b3de1bed0f 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -18,8 +18,10 @@ class DeploymentEntity < Grape::Entity
end
end
+ expose :created_at
expose :tag
expose :last?
+
expose :user, using: UserEntity
expose :commit, using: CommitEntity
expose :deployable, using: BuildEntity
diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb
new file mode 100644
index 00000000000..cba5c3f311f
--- /dev/null
+++ b/app/serializers/deployment_serializer.rb
@@ -0,0 +1,8 @@
+class DeploymentSerializer < BaseSerializer
+ entity DeploymentEntity
+
+ def represent_concise(resource, opts = {})
+ opts[:only] = [:iid, :id, :sha, :created_at, :tag, :last?, :id, ref: [:name]]
+ represent(resource, opts)
+ end
+end
diff --git a/app/serializers/merge_request_create_entity.rb b/app/serializers/merge_request_create_entity.rb
new file mode 100644
index 00000000000..11234313293
--- /dev/null
+++ b/app/serializers/merge_request_create_entity.rb
@@ -0,0 +1,7 @@
+class MergeRequestCreateEntity < Grape::Entity
+ expose :iid
+
+ expose :url do |merge_request|
+ Gitlab::UrlBuilder.build(merge_request)
+ end
+end
diff --git a/app/serializers/merge_request_create_serializer.rb b/app/serializers/merge_request_create_serializer.rb
new file mode 100644
index 00000000000..08daf473319
--- /dev/null
+++ b/app/serializers/merge_request_create_serializer.rb
@@ -0,0 +1,3 @@
+class MergeRequestCreateSerializer < BaseSerializer
+ entity MergeRequestCreateEntity
+end
diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb
new file mode 100644
index 00000000000..a471a7e6a88
--- /dev/null
+++ b/app/serializers/project_entity.rb
@@ -0,0 +1,14 @@
+class ProjectEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :name
+
+ expose :full_path do |project|
+ namespace_project_path(project.namespace, project)
+ end
+
+ expose :full_name do |project|
+ project.full_name
+ end
+end
diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb
index 944472f3e51..188c3747f18 100644
--- a/app/serializers/status_entity.rb
+++ b/app/serializers/status_entity.rb
@@ -7,6 +7,9 @@ class StatusEntity < Grape::Entity
expose :details_path
expose :favicon do |status|
- ActionController::Base.helpers.image_path(File.join('ci_favicons', "#{status.favicon}.ico"))
+ dir = 'ci_favicons'
+ dir = File.join(dir, 'dev') if Rails.env.development?
+
+ ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico"))
end
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index d45da5180e1..bc0e7ad4e39 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -28,7 +28,7 @@ module MergeRequests
def find_target_project
return target_project if target_project.present? && can?(current_user, :read_project, target_project)
- project.forked_from_project || project
+ project.default_merge_request_target
end
def find_target_branch
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
new file mode 100644
index 00000000000..738cedbaed7
--- /dev/null
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -0,0 +1,54 @@
+module MergeRequests
+ class CreateFromIssueService < MergeRequests::CreateService
+ def execute
+ return error('Invalid issue iid') unless issue_iid.present? && issue.present?
+
+ result = CreateBranchService.new(project, current_user).execute(branch_name, ref)
+ return result if result[:status] == :error
+
+ SystemNoteService.new_issue_branch(issue, project, current_user, branch_name)
+
+ new_merge_request = create(merge_request)
+
+ if new_merge_request.valid?
+ success(new_merge_request)
+ else
+ error(new_merge_request.errors)
+ end
+ end
+
+ private
+
+ def issue_iid
+ @isssue_iid ||= params.delete(:issue_iid)
+ end
+
+ def issue
+ @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid)
+ end
+
+ def branch_name
+ @branch_name ||= issue.to_branch_name
+ end
+
+ def ref
+ project.default_branch || 'master'
+ end
+
+ def merge_request
+ MergeRequests::BuildService.new(project, current_user, merge_request_params).execute
+ end
+
+ def merge_request_params
+ {
+ source_project_id: project.id,
+ source_branch: branch_name,
+ target_project_id: project.id
+ }
+ end
+
+ def success(merge_request)
+ super().merge(merge_request: merge_request)
+ end
+ end
+end
diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb
index 3cf4264ce9b..121385afca3 100644
--- a/app/services/projects/enable_deploy_key_service.rb
+++ b/app/services/projects/enable_deploy_key_service.rb
@@ -4,7 +4,10 @@ module Projects
key = accessible_keys.find_by(id: params[:key_id] || params[:id])
return unless key
- project.deploy_keys << key
+ unless project.deploy_keys.include?(key)
+ project.deploy_keys << key
+ end
+
key
end
diff --git a/app/services/projects/upload_service.rb b/app/services/projects/upload_service.rb
deleted file mode 100644
index be34d4fa9b8..00000000000
--- a/app/services/projects/upload_service.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module Projects
- class UploadService < BaseService
- def initialize(project, file)
- @project, @file = project, file
- end
-
- def execute
- return nil unless @file && @file.size <= max_attachment_size
-
- uploader = FileUploader.new(@project)
- uploader.store!(@file)
-
- uploader.to_h
- end
-
- private
-
- def max_attachment_size
- current_application_settings.max_attachment_size.megabytes.to_i
- end
- end
-end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index b6e88b0280f..8ae61694b50 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -281,7 +281,7 @@ class TodoService
def attributes_for_target(target)
attributes = {
- project_id: target.project.id,
+ project_id: target&.project&.id,
target_id: target.id,
target_type: target.class.name,
commit_id: nil
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
new file mode 100644
index 00000000000..6c5b2baff41
--- /dev/null
+++ b/app/services/upload_service.rb
@@ -0,0 +1,20 @@
+class UploadService
+ def initialize(model, file, uploader_class = FileUploader)
+ @model, @file, @uploader_class = model, file, uploader_class
+ end
+
+ def execute
+ return nil unless @file && @file.size <= max_attachment_size
+
+ uploader = @uploader_class.new(@model)
+ uploader.store!(@file)
+
+ uploader.to_h
+ end
+
+ private
+
+ def max_attachment_size
+ current_application_settings.max_attachment_size.megabytes.to_i
+ end
+end
diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb
index e84944ed411..3e36ec91205 100644
--- a/app/uploaders/artifact_uploader.rb
+++ b/app/uploaders/artifact_uploader.rb
@@ -30,8 +30,4 @@ class ArtifactUploader < GitlabUploader
def filename
file.try(:filename)
end
-
- def exists?
- file.try(:exists?)
- end
end
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index d2783ce5b2f..7e94218c23d 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -26,11 +26,11 @@ class FileUploader < GitlabUploader
File.join(CarrierWave.root, base_dir, model.path_with_namespace)
end
- attr_accessor :project
+ attr_accessor :model
attr_reader :secret
- def initialize(project, secret = nil)
- @project = project
+ def initialize(model, secret = nil)
+ @model = model
@secret = secret || generate_secret
end
@@ -38,10 +38,6 @@ class FileUploader < GitlabUploader
File.join(dynamic_path_segment, @secret)
end
- def model
- project
- end
-
def relative_path
self.file.path.sub("#{dynamic_path_segment}/", '')
end
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index d662ba6820c..e0a6c9b4067 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -33,4 +33,8 @@ class GitlabUploader < CarrierWave::Uploader::Base
def relative_path
self.file.path.sub("#{root}/", '')
end
+
+ def exists?
+ file.try(:exists?)
+ end
end
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index faab539b8e0..95a891111e1 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -9,10 +9,6 @@ class LfsObjectUploader < GitlabUploader
"#{Gitlab.config.lfs.storage_path}/tmp/cache"
end
- def exists?
- file.try(:exists?)
- end
-
def filename
model.oid[4..-1]
end
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
new file mode 100644
index 00000000000..969b0a20d38
--- /dev/null
+++ b/app/uploaders/personal_file_uploader.rb
@@ -0,0 +1,15 @@
+class PersonalFileUploader < FileUploader
+ def self.dynamic_path_segment(model)
+ File.join(CarrierWave.root, model_path(model))
+ end
+
+ private
+
+ def secure_url
+ File.join(self.class.model_path(model), secret, file.filename)
+ end
+
+ def self.model_path(model)
+ File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
+ end
+end
diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb
new file mode 100644
index 00000000000..d992b0c3725
--- /dev/null
+++ b/app/validators/dynamic_path_validator.rb
@@ -0,0 +1,215 @@
+# DynamicPathValidator
+#
+# Custom validator for GitLab path values.
+# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project`
+#
+# Values are checked for formatting and exclusion from a list of reserved path
+# names.
+class DynamicPathValidator < ActiveModel::EachValidator
+ # All routes that appear on the top level must be listed here.
+ # This will make sure that groups cannot be created with these names
+ # as these routes would be masked by the paths already in place.
+ #
+ # Example:
+ # /api/api-project
+ #
+ # the path `api` shouldn't be allowed because it would be masked by `api/*`
+ #
+ TOP_LEVEL_ROUTES = %w[
+ -
+ .well-known
+ abuse_reports
+ admin
+ all
+ api
+ assets
+ autocomplete
+ ci
+ dashboard
+ explore
+ files
+ groups
+ health_check
+ help
+ hooks
+ import
+ invites
+ issues
+ jwt
+ koding
+ member
+ merge_requests
+ new
+ notes
+ notification_settings
+ oauth
+ profile
+ projects
+ public
+ repository
+ robots.txt
+ s
+ search
+ sent_notifications
+ services
+ snippets
+ teams
+ u
+ unicorn_test
+ unsubscribes
+ uploads
+ users
+ ].freeze
+
+ # This list should contain all words following `/*namespace_id/:project_id` in
+ # routes that contain a second wildcard.
+ #
+ # Example:
+ # /*namespace_id/:project_id/badges/*ref/build
+ #
+ # If `badges` was allowed as a project/group name, we would not be able to access the
+ # `badges` route for those projects:
+ #
+ # Consider a namespace with path `foo/bar` and a project called `badges`.
+ # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg`
+ #
+ # When accessing this path the route would be matched to the `badges` path
+ # with the following params:
+ # - namespace_id: `foo`
+ # - project_id: `bar`
+ # - ref: `badges/master`
+ #
+ # Failing to find the project, this would result in a 404.
+ #
+ # By rejecting `badges` the router can _count_ on the fact that `badges` will
+ # be preceded by the `namespace/project`.
+ WILDCARD_ROUTES = %w[
+ badges
+ blame
+ blob
+ builds
+ commits
+ create
+ create_dir
+ edit
+ environments/folders
+ files
+ find_file
+ gitlab-lfs/objects
+ info/lfs/objects
+ new
+ preview
+ raw
+ refs
+ tree
+ update
+ wikis
+ ].freeze
+
+ # These are all the paths that follow `/groups/*id/ or `/groups/*group_id`
+ # We need to reject these because we have a `/groups/*id` page that is the same
+ # as the `/*id`.
+ #
+ # If we would allow a subgroup to be created with the name `activity` then
+ # this group would not be accessible through `/groups/parent/activity` since
+ # this would map to the activity-page of it's parent.
+ GROUP_ROUTES = %w[
+ activity
+ analytics
+ audit_events
+ avatar
+ edit
+ group_members
+ hooks
+ issues
+ labels
+ ldap
+ ldap_group_links
+ merge_requests
+ milestones
+ notification_setting
+ pipeline_quota
+ projects
+ subgroups
+ ].freeze
+
+ CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze
+
+ def self.without_reserved_wildcard_paths_regex
+ @without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES)
+ end
+
+ def self.without_reserved_child_paths_regex
+ @without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES)
+ end
+
+ # This is used to validate a full path.
+ # It doesn't match paths
+ # - Starting with one of the top level words
+ # - Containing one of the child level words in the middle of a path
+ def self.regex_excluding_child_paths(child_routes)
+ reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES)
+ not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))}
+
+ reserved_child_level_words = Regexp.union(child_routes)
+ not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))}
+
+ %r{#{not_starting_in_reserved_word}
+ #{not_containing_reserved_child}
+ #{Gitlab::Regex.full_namespace_regex}}x
+ end
+
+ def self.valid?(path)
+ path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path)
+ end
+
+ def self.full_path_reserved?(path)
+ path = path.to_s.downcase
+ _project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse)
+
+ wildcard_reserved?(path) || child_reserved?(namespace_parts)
+ end
+
+ def self.child_reserved?(path)
+ return false unless path
+
+ path !~ without_reserved_child_paths_regex
+ end
+
+ def self.wildcard_reserved?(path)
+ return false unless path
+
+ path !~ without_reserved_wildcard_paths_regex
+ end
+
+ delegate :full_path_reserved?,
+ :child_reserved?,
+ to: :class
+
+ def path_reserved_for_record?(record, value)
+ full_path = record.respond_to?(:full_path) ? record.full_path : value
+
+ # For group paths the entire path cannot contain a reserved child word
+ # The path doesn't contain the last `_project_part` so we need to validate
+ # if the entire path.
+ # Example:
+ # A *group* with full path `parent/activity` is reserved.
+ # A *project* with full path `parent/activity` is allowed.
+ if record.is_a? Group
+ child_reserved?(full_path)
+ else
+ full_path_reserved?(full_path)
+ end
+ end
+
+ def validate_each(record, attribute, value)
+ unless value =~ Gitlab::Regex.namespace_regex
+ record.errors.add(attribute, Gitlab::Regex.namespace_regex_message)
+ return
+ end
+
+ if path_reserved_for_record?(record, value)
+ record.errors.add(attribute, "#{value} is a reserved name")
+ end
+ end
+end
diff --git a/app/validators/namespace_validator.rb b/app/validators/namespace_validator.rb
deleted file mode 100644
index 77ca033e97f..00000000000
--- a/app/validators/namespace_validator.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-# NamespaceValidator
-#
-# Custom validator for GitLab namespace values.
-#
-# Values are checked for formatting and exclusion from a list of reserved path
-# names.
-class NamespaceValidator < ActiveModel::EachValidator
- RESERVED = %w[
- .well-known
- admin
- all
- assets
- ci
- dashboard
- files
- groups
- help
- hooks
- issues
- merge_requests
- new
- notes
- profile
- projects
- public
- repository
- robots.txt
- s
- search
- services
- snippets
- teams
- u
- unsubscribes
- users
- ].freeze
-
- WILDCARD_ROUTES = %w[tree commits wikis new edit create update logs_tree
- preview blob blame raw files create_dir find_file
- artifacts graphs refs badges].freeze
-
- STRICT_RESERVED = (RESERVED + WILDCARD_ROUTES).freeze
-
- def self.valid?(value)
- !reserved?(value) && follow_format?(value)
- end
-
- def self.reserved?(value, strict: false)
- if strict
- STRICT_RESERVED.include?(value)
- else
- RESERVED.include?(value)
- end
- end
-
- def self.follow_format?(value)
- value =~ Gitlab::Regex.namespace_regex
- end
-
- delegate :reserved?, :follow_format?, to: :class
-
- def validate_each(record, attribute, value)
- unless follow_format?(value)
- record.errors.add(attribute, Gitlab::Regex.namespace_regex_message)
- end
-
- strict = record.is_a?(Group) && record.parent_id
-
- if reserved?(value, strict: strict)
- record.errors.add(attribute, "#{value} is a reserved name")
- end
- end
-end
diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb
deleted file mode 100644
index ee2ae65be7b..00000000000
--- a/app/validators/project_path_validator.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# ProjectPathValidator
-#
-# Custom validator for GitLab project path values.
-#
-# Values are checked for formatting and exclusion from a list of reserved path
-# names.
-class ProjectPathValidator < ActiveModel::EachValidator
- # All project routes with wildcard argument must be listed here.
- # Otherwise it can lead to routing issues when route considered as project name.
- #
- # Example:
- # /group/project/tree/deploy_keys
- #
- # without tree as reserved name routing can match 'group/project' as group name,
- # 'tree' as project name and 'deploy_keys' as route.
- #
- RESERVED = (NamespaceValidator::STRICT_RESERVED -
- %w[dashboard help ci admin search notes services assets profile public]).freeze
-
- def self.valid?(value)
- !reserved?(value)
- end
-
- def self.reserved?(value)
- RESERVED.include?(value)
- end
-
- delegate :reserved?, to: :class
-
- def validate_each(record, attribute, value)
- if reserved?(value)
- record.errors.add(attribute, "#{value} is a reserved name")
- end
- end
-end
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 8c9fdc9ae42..53f0a1e7fde 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -73,6 +73,12 @@
= container_reg
%span.light.pull-right
= boolean_to_icon Gitlab.config.registry.enabled
+ - gitlab_pages = 'GitLab Pages'
+ - gitlab_pages_enabled = Gitlab.config.pages.enabled
+ %p{ "aria-label" => "#{gitlab_pages}: status " + (gitlab_pages_enabled ? "on" : "off") }
+ = gitlab_pages
+ %span.light.pull-right
+ = boolean_to_icon gitlab_pages_enabled
.col-md-4
%h4
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
new file mode 100644
index 00000000000..6217d5fb135
--- /dev/null
+++ b/app/views/admin/hooks/_form.html.haml
@@ -0,0 +1,40 @@
+= form_errors(hook)
+
+.form-group
+ = form.label :url, 'URL', class: 'control-label'
+ .col-sm-10
+ = form.text_field :url, class: 'form-control'
+.form-group
+ = form.label :token, 'Secret Token', class: 'control-label'
+ .col-sm-10
+ = form.text_field :token, class: 'form-control'
+ %p.help-block
+ Use this token to validate received payloads
+.form-group
+ = form.label :url, 'Trigger', class: 'control-label'
+ .col-sm-10.prepend-top-10
+ %div
+ System hook will be triggered on set of events like creating project
+ or adding ssh key. But you can also enable extra triggers like Push events.
+
+ .prepend-top-default
+ = form.check_box :push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :push_events, class: 'list-label' do
+ %strong Push events
+ %p.light
+ This url will be triggered by a push to the repository
+ %div
+ = form.check_box :tag_push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :tag_push_events, class: 'list-label' do
+ %strong Tag push events
+ %p.light
+ This url will be triggered when a new tag is pushed to the repository
+.form-group
+ = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox'
+ .col-sm-10
+ .checkbox
+ = form.label :enable_ssl_verification do
+ = form.check_box :enable_ssl_verification
+ %strong Enable SSL verification
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
new file mode 100644
index 00000000000..0777f5e2629
--- /dev/null
+++ b/app/views/admin/hooks/edit.html.haml
@@ -0,0 +1,14 @@
+- page_title 'Edit System Hook'
+%h3.page-title
+ Edit System Hook
+
+%p.light
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
+ used for binding events when GitLab creates a User or Project.
+
+%hr
+
+= form_for @hook, as: :hook, url: admin_hook_path, html: { class: 'form-horizontal' } do |f|
+ = render partial: 'form', locals: { form: f, hook: @hook }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn btn-create'
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index d9c7948763a..71117758921 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,57 +1,17 @@
-- page_title "System Hooks"
+- page_title 'System Hooks'
%h3.page-title
System hooks
%p.light
- #{link_to "System hooks ", help_page_path("system_hooks/system_hooks"), class: "vlink"} can be
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
used for binding events when GitLab creates a User or Project.
%hr
-
= form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f|
- = form_errors(@hook)
-
- .form-group
- = f.label :url, 'URL', class: 'control-label'
- .col-sm-10
- = f.text_field :url, class: 'form-control'
- .form-group
- = f.label :token, 'Secret Token', class: 'control-label'
- .col-sm-10
- = f.text_field :token, class: 'form-control'
- %p.help-block
- Use this token to validate received payloads
- .form-group
- = f.label :url, "Trigger", class: 'control-label'
- .col-sm-10.prepend-top-10
- %div
- System hook will be triggered on set of events like creating project
- or adding ssh key. But you can also enable extra triggers like Push events.
-
- .prepend-top-default
- = f.check_box :push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :push_events, class: 'list-label' do
- %strong Push events
- %p.light
- This url will be triggered by a push to the repository
- %div
- = f.check_box :tag_push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :tag_push_events, class: 'list-label' do
- %strong Tag push events
- %p.light
- This url will be triggered when a new tag is pushed to the repository
- .form-group
- = f.label :enable_ssl_verification, "SSL verification", class: 'control-label checkbox'
- .col-sm-10
- .checkbox
- = f.label :enable_ssl_verification do
- = f.check_box :enable_ssl_verification
- %strong Enable SSL verification
+ = render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
- = f.submit "Add system hook", class: "btn btn-create"
+ = f.submit 'Add system hook', class: 'btn btn-create'
%hr
- if @hooks.any?
@@ -62,11 +22,12 @@
- @hooks.each do |hook|
%li
.controls
- = link_to 'Test hook', admin_hook_test_path(hook), class: "btn btn-sm"
- = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-remove btn-sm"
+ = link_to 'Test hook', test_admin_hook_path(hook), class: 'btn btn-sm'
+ = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm'
+ = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
.monospace= hook.url
%div
- %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger|
- if hook.send(trigger)
%span.label.label-gray= trigger.titleize
- %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"}
+ %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 840d843f069..89d0bbb7126 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -175,11 +175,7 @@
.panel-body
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p Deleting a user has the following effects:
- %ul
- %li All user content like authored issues, snippets, comments will be removed
- - rp = @user.personal_projects.count
- - unless rp.zero?
- %li #{pluralize rp, 'personal project'} will be removed and cannot be restored
+ = render 'users/deletion_guidance', user: @user
%br
= link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
- else
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 549364761e6..78c5b0c1dda 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -3,7 +3,7 @@
.diff-file.file-holder
.js-file-title.file-title
- = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion)
+ = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion)
.diff-content.code.js-syntax-highlight
%table
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 8440fb3d785..38e85168f40 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -20,7 +20,7 @@
= discussion.author.to_reference
started a discussion
- - url = discussion_diff_path(discussion)
+ - url = discussion_path(discussion)
- if discussion.for_commit? && @noteable != discussion.noteable
on
- commit = discussion.noteable
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 34789808f10..964473ee3e0 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,6 +1,6 @@
.discussion-notes
%ul.notes{ data: { discussion_id: discussion.id } }
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
+ = render partial: "shared/notes/note", collection: discussion.notes, as: :note
- if current_user
.discussion-reply-holder
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
index 8ab9747efc5..cdcac7e4264 100644
--- a/app/views/layouts/nav/_project.html.haml
+++ b/app/views/layouts/nav/_project.html.haml
@@ -38,7 +38,7 @@
%span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count)
- if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :environments]) do
+ = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do
= link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
%span
Pipelines
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index f5e7ea7710d..e9e06e5c8e3 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -11,7 +11,7 @@
- preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project)
- if current_user
:javascript
- window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
+ window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
window.preview_markdown_path = "#{preview_markdown_path}";
- content_for :header_content do
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index d843cacd52d..73f33e69d68 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -118,11 +118,7 @@
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p
Deleting an account has the following effects:
- %ul
- %li All user content like authored issues, snippets, comments will be removed
- - rp = current_user.personal_projects.count
- - unless rp.zero?
- %li #{pluralize rp, 'personal project'} will be removed and cannot be restored
+ = render 'users/deletion_guidance', user: current_user
= link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove"
- else
- if @user.solo_owned_groups.present?
diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml
index 9e49c93388a..34d5c3b7285 100644
--- a/app/views/projects/artifacts/_tree_directory.html.haml
+++ b/app/views/projects/artifacts/_tree_directory.html.haml
@@ -3,6 +3,6 @@
%tr.tree-item{ 'data-link' => path_to_directory }
%td.tree-item-file-name
= tree_icon('folder', '755', directory.name)
- %span.str-truncated
- = link_to directory.name, path_to_directory
+ = link_to path_to_directory do
+ %span.str-truncated= directory.name
%td
diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml
index 36fb4c998c9..ce7e25d774b 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -1,9 +1,10 @@
- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path)
%tr.tree-item{ 'data-link' => path_to_file }
+ - blob = file.blob
%td.tree-item-file-name
- = tree_icon('file', '664', file.name)
- %span.str-truncated
- = link_to file.name, path_to_file
+ = tree_icon('file', blob.mode, blob.name)
+ = link_to path_to_file do
+ %span.str-truncated= blob.name
%td
- = number_to_human_size(file.metadata[:size], precision: 2)
+ = number_to_human_size(blob.size, precision: 2)
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index de8c173f26f..9fbb30f7c7c 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -1,13 +1,23 @@
-- page_title 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+= render "projects/pipelines/head"
-.top-block.row-content-block.clearfix
- .pull-right
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build),
- rel: 'nofollow', download: '', class: 'btn btn-default download' do
- = icon('download')
- Download artifacts archive
+= render "projects/builds/header", show_controls: false
.tree-holder
+ .nav-block
+ .tree-controls
+ = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build),
+ rel: 'nofollow', download: '', class: 'btn btn-default download' do
+ = icon('download')
+ Download artifacts archive
+
+ %ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
+ - path_breadcrumbs do |title, path|
+ %li
+ = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path)
+
.tree-content-holder
%table.table.tree-table
%thead
diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml
new file mode 100644
index 00000000000..d8da83b9a80
--- /dev/null
+++ b/app/views/projects/artifacts/file.html.haml
@@ -0,0 +1,33 @@
+- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+= render "projects/pipelines/head"
+
+= render "projects/builds/header", show_controls: false
+
+#tree-holder.tree-holder
+ .nav-block
+ %ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
+ - path_breadcrumbs do |title, path|
+ - title = truncate(title, length: 40)
+ %li
+ - if path == @path
+ = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do
+ %strong= title
+ - else
+ = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path)
+
+
+ %article.file-holder
+ - blob = @entry.blob
+ .js-file-title.file-title-flex-parent
+ = render 'projects/blob/header_content', blob: blob
+
+ .file-actions.hidden-xs
+ = render 'projects/blob/viewer_switcher', blob: blob
+
+ .btn-group{ role: "group" }<
+ = copy_blob_source_button(blob)
+ = open_raw_blob_button(blob)
+
+ = render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 3f12d64d044..f04df441ccb 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -6,17 +6,14 @@
%li
= link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
= @project.path
- - tree_breadcrumbs(@tree, 6) do |title, path|
+ - path_breadcrumbs do |title, path|
+ - title = truncate(title, length: 40)
%li
- - if path
- - if path.end_with?(@path)
- = link_to namespace_project_blob_path(@project.namespace, @project, path) do
- %strong
- = truncate(title, length: 40)
- - else
- = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path)
+ - if path == @path
+ = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do
+ %strong= title
- else
- = link_to title, '#'
+ = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path))
%ul.blob-commit-info.hidden-xs
- blob_commit = @repository.last_commit_for_path(@commit.id, blob.path)
@@ -25,5 +22,4 @@
#blob-content-holder.blob-content-holder
%article.file-holder
= render "projects/blob/header", blob: blob
-
= render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 219dc14645b..cd098acda81 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -1,15 +1,6 @@
- blame = local_assigns.fetch(:blame, false)
.js-file-title.file-title-flex-parent
- .file-header-content
- = blob_icon blob.mode, blob.name
-
- %strong.file-title-name
- = blob.name
-
- = copy_file_path_button(blob.path)
-
- %small
- = number_to_human_size(blob.raw_size)
+ = render 'projects/blob/header_content', blob: blob
.file-actions.hidden-xs
= render 'projects/blob/viewer_switcher', blob: blob unless blame
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
new file mode 100644
index 00000000000..98bedae650a
--- /dev/null
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -0,0 +1,10 @@
+.file-header-content
+ = blob_icon blob.mode, blob.name
+
+ %strong.file-title-name
+ = blob.name
+
+ = copy_file_path_button(blob.path)
+
+ %small
+ = number_to_human_size(blob.raw_size)
diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml
index e75ce305440..0f424334521 100644
--- a/app/views/projects/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml
@@ -28,8 +28,9 @@
":value" => "issue.assignee.id",
"v-if" => "issue.assignee" }
.dropdown
- %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true" },
+ %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" },
":data-issuable-id" => "issue.id",
+ ":data-selected" => "assigneeId",
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
Select assignee
= icon("chevron-down")
diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml
index 104db85809c..a0f8f105d9a 100644
--- a/app/views/projects/builds/_header.html.haml
+++ b/app/views/projects/builds/_header.html.haml
@@ -1,10 +1,13 @@
+- show_controls = local_assigns.fetch(:show_controls, true)
- pipeline = @build.pipeline
.content-block.build-header.top-area
.header-content
= render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
- Job
- %strong.js-build-id ##{@build.id}
+ %strong
+ Job
+ = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do
+ \##{@build.id}
in pipeline
= link_to pipeline_path(pipeline) do
%strong ##{pipeline.id}
@@ -15,13 +18,16 @@
= link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do
%code
= @build.ref
- - if @build.user
- = render "user"
+
+ = render "projects/builds/user" if @build.user
+
= time_ago_with_tooltip(@build.created_at)
- .nav-controls
- - if can?(current_user, :create_issue, @project) && @build.failed?
- = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted'
- - if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post
- %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+
+ - if show_controls
+ .nav-controls
+ - if can?(current_user, :create_issue, @project) && @build.failed?
+ = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted'
+ - if can?(current_user, :update_build, @build) && @build.retryable?
+ = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post
+ %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" }
+ = icon('angle-double-left')
diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml
index c4159ce1a36..43191fae9e6 100644
--- a/app/views/projects/builds/_sidebar.html.haml
+++ b/app/views/projects/builds/_sidebar.html.haml
@@ -48,7 +48,7 @@
- if @build.merge_request
%p.build-detail-row
%span.build-light-text Merge Request:
- = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request)
+ = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold'
- if @build.duration
%p.build-detail-row
%span.build-light-text Duration:
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index f604d6e5fbb..64adb70cb81 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -61,19 +61,20 @@
%span.commit-info.branches
%i.fa.fa-spinner.fa-spin
- - if @commit.status
+ - if @commit.last_pipeline
+ - last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
.status-icon-container{ class: "ci-status-icon-#{@commit.status}" }
- = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id) do
- = ci_icon_for_status(@commit.status)
+ = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do
+ = ci_icon_for_status(last_pipeline.status)
Pipeline
- = link_to "##{@commit.latest_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id), class: "monospace"
- = ci_label_for_status(@commit.status)
- - if @commit.latest_pipeline.stages.any?
+ = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id), class: "monospace"
+ = ci_label_for_status(last_pipeline.status)
+ - if last_pipeline.stages.any?
.mr-widget-pipeline-graph
- = render 'shared/mini_pipeline_graph', pipeline: @commit.latest_pipeline, klass: 'js-commit-pipeline-graph'
+ = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph'
in
- = time_interval_in_words @commit.pipelines.total_duration
+ = time_interval_in_words last_pipeline.duration
:javascript
$(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}");
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 0d11da2451a..16d2646cb4e 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -1,9 +1,11 @@
- @no_container = true
+- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : ''
+- limited_container_width = fluid_layout || diff_view == :inline ? '' : 'limit-container-width'
- page_title "#{@commit.title} (#{@commit.short_id})", "Commits"
- page_description @commit.description
= render "projects/commits/head"
-%div{ class: container_class }
+.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
- if @commit.status
= render "ci_menu"
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index 4cfbd9add00..74756b58439 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -10,25 +10,4 @@
= render @deploy_keys.form_partial_path
.col-lg-9.col-lg-offset-3
%hr
- .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys
- %h5.prepend-top-0
- Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size})
- - if @deploy_keys.any_keys_enabled?
- %ul.well-list
- = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key
- - else
- .settings-message.text-center
- No deploy keys found. Create one with the form above.
- %h5.prepend-top-default
- Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size})
- - if @deploy_keys.any_available_project_keys_enabled?
- %ul.well-list
- = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key
- - else
- .settings-message.text-center
- No deploy keys from your projects could be found. Create one with the form above or add existing one below.
- - if @deploy_keys.any_available_public_keys_enabled?
- %h5.prepend-top-default
- Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size})
- %ul.well-list
- = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key
+ #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 3e426ee9e7d..7439b8a66f7 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -35,6 +35,6 @@
- else
= diff_line_content(line.text)
-- if line_discussions
+- if line_discussions&.any?
- discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?))
= render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 85e442e115c..50e0bad3ccf 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -60,7 +60,7 @@
git init
git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')}
git add .
- git commit
+ git commit -m "Initial commit"
git push -u origin master
%fieldset
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index 766f119116f..e8f8fbbcf09 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -5,7 +5,7 @@
= page_specific_javascript_bundle_tag('monitoring')
= render "projects/pipelines/head"
-.prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" }
+#js-metrics.prometheus-container{ class: container_class, data: { has_metrics: "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } }
.top-area
.row
.col-sm-6
diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml
index 8faad351463..676b7c345bc 100644
--- a/app/views/projects/hooks/_index.html.haml
+++ b/app/views/projects/hooks/_index.html.haml
@@ -1 +1,23 @@
-= render 'shared/web_hooks/form', hook: @hook, hooks: @hooks, url_components: [@project.namespace.becomes(Namespace), @project]
+.row.prepend-top-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = page_title
+ %p
+ #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
+ used for binding events when something is happening within the project.
+
+ .col-lg-9.append-bottom-default
+ = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f|
+ = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
+ = f.submit 'Add webhook', class: 'btn btn-create'
+
+ %hr
+ %h5.prepend-top-default
+ Webhooks (#{@hooks.count})
+ - if @hooks.any?
+ %ul.well-list
+ - @hooks.each do |hook|
+ = render 'project_hook', hook: hook
+ - else
+ %p.settings-message.text-center.append-bottom-0
+ No webhooks found, add one in the form above.
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
new file mode 100644
index 00000000000..7998713be1f
--- /dev/null
+++ b/app/views/projects/hooks/edit.html.haml
@@ -0,0 +1,14 @@
+= render 'projects/settings/head'
+
+.row.prepend-top-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = page_title
+ %p
+ #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
+ used for binding events when something is happening within the project.
+ .col-lg-9.append-bottom-default
+ = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f|
+ = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
+ = f.submit 'Save changes', class: 'btn btn-create'
+
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 13e2150f997..6bc6bf76e18 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -1,9 +1,29 @@
- if can?(current_user, :push_code, @project)
- .pull-right
- #new-branch.new-branch{ 'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue) }
- = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid),
- method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do
- New branch
- = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do
- = icon('exclamation-triangle')
- New branch unavailable
+ .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue), create_mr_path: create_merge_request_namespace_project_issue_path(@project.namespace, @project, @issue), create_branch_path: namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } }
+ .btn-group.unavailable
+ %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
+ = icon('spinner', class: 'fa-spin')
+ %span.text
+ Checking branch availability…
+ .btn-group.available.hide
+ %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } }
+ %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } }
+ = icon('caret-down')
+ %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+ %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } }
+ .menu-item
+ .icon-container
+ = icon('check')
+ .description
+ %strong Create a merge request
+ %span
+ Creates a branch named after this issue and a merge request. The source branch is '#{@project.default_branch}' by default.
+ %li.divider.droplab-item-ignore
+ %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } }
+ .menu-item
+ .icon-container
+ = icon('check')
+ .description
+ %strong Create a branch
+ %span
+ Creates a branch named after this issue. The source branch is '#{@project.default_branch}' by default.
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 2a871966aa8..1418ad73553 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -70,8 +70,11 @@
// This element is filled in using JavaScript.
.content-block.content-block-small
- = render 'new_branch' unless @issue.confidential?
- = render 'award_emoji/awards_block', awardable: @issue, inline: true
+ .row
+ .col-sm-6
+ = render 'award_emoji/awards_block', awardable: @issue, inline: true
+ .col-sm-6.new-branch-col
+ = render 'new_branch' unless @issue.confidential?
%section.issuable-discussion
= render 'projects/issues/discussion'
diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml
index 8d134aaac67..9cf24e10842 100644
--- a/app/views/projects/merge_requests/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/_new_compare.html.haml
@@ -38,7 +38,7 @@
.panel-heading
Target branch
.panel-body.clearfix
- - projects = @project.forked_from_project.nil? ? [@project] : [@project, @project.forked_from_project]
+ - projects = target_projects(@project)
.merge-request-select.dropdown
= f.hidden_field :target_project_id
= dropdown_toggle f.object.target_project.path_with_namespace, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml
index 547be78992e..11b0c55be0b 100644
--- a/app/views/projects/merge_requests/show/_versions.html.haml
+++ b/app/views/projects/merge_requests/show/_versions.html.haml
@@ -35,7 +35,7 @@
%span.dropdown.inline.mr-version-compare-dropdown
%a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} }
%span
- - if @start_sha
+ - if @start_version
version #{version_index(@start_version)}
- else
#{@merge_request.target_branch}
@@ -59,7 +59,7 @@
%small
= time_ago_with_tooltip(merge_request_diff.created_at)
%li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do
+ = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do
%strong
#{@merge_request.target_branch} (base)
.monospace= short_sha(@merge_request_diff.base_commit_sha)
@@ -75,13 +75,15 @@
= succeed '.' do
%code= @merge_request.target_branch
- - if @diff_notes_disabled
+ - if @start_version || !@merge_request_diff.latest?
.comments-disabled-notif.content-block
= icon('info-circle')
- - if @start_sha
- Comments are disabled because you're comparing two versions of this merge request.
+ Not all comments are displayed because you're
+ - if @start_version
+ comparing two versions
- else
- Discussions on this version of the merge request are displayed but comment creation is disabled.
+ viewing an old version
+ of this merge request.
.pull-right
= link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm'
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
new file mode 100644
index 00000000000..d70ec8a6062
--- /dev/null
+++ b/app/views/projects/notes/_actions.html.haml
@@ -0,0 +1,44 @@
+- access = note_max_access_for_user(note)
+- if access
+ %span.note-role= access
+
+- if note.resolvable?
+ - can_resolve = can?(current_user, :resolve_note, note)
+ %resolve-btn{ "project-path" => project_path(note.project),
+ "discussion-id" => note.discussion_id(@noteable),
+ ":note-id" => note.id,
+ ":resolved" => note.resolved?,
+ ":can-resolve" => can_resolve,
+ ":author-name" => "'#{j(note.author.name)}'",
+ "author-avatar" => note.author.avatar_url,
+ ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'",
+ ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'",
+ "v-show" => "#{can_resolve || note.resolved?}",
+ "inline-template" => true,
+ "ref" => "note_#{note.id}" }
+
+ %button.note-action-button.line-resolve-btn{ type: "button",
+ class: ("is-disabled" unless can_resolve),
+ ":class" => "{ 'is-active': isResolved }",
+ ":aria-label" => "buttonText",
+ "@click" => "resolve",
+ ":title" => "buttonText",
+ ":ref" => "'button'" }
+
+ = icon('spin spinner', 'v-show' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
+ %div{ 'v-show' => '!loading' }= render 'shared/icons/icon_status_success.svg'
+
+- if current_user
+ - if note.emoji_awardable?
+ - user_authored = note.user_authored?(current_user)
+ = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
+ = icon('spinner spin')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
+ %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+
+ - if note_editable
+ = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
+ = icon('pencil', class: 'link-highlight')
+ = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
+ = icon('trash-o', class: 'danger-highlight')
diff --git a/app/views/projects/notes/_edit.html.haml b/app/views/projects/notes/_edit.html.haml
new file mode 100644
index 00000000000..f1e251d65b7
--- /dev/null
+++ b/app/views/projects/notes/_edit.html.haml
@@ -0,0 +1,3 @@
+.original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
+ #{note.note}
+%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note
diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml
index 8b4e5928e0d..a1efc0b051a 100644
--- a/app/views/projects/notes/_edit_form.html.haml
+++ b/app/views/projects/notes/_edit_form.html.haml
@@ -7,7 +7,7 @@
= render 'projects/notes/hints'
.note-form-actions.clearfix
- .settings-message.note-edit-warning.js-edit-warning
+ .settings-message.note-edit-warning.js-finish-edit-warning
Finish editing this message first!
= submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button'
%button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
deleted file mode 100644
index 7afccb3900a..00000000000
--- a/app/views/projects/notes/_note.html.haml
+++ /dev/null
@@ -1,101 +0,0 @@
-- return unless note.author
-- return if note.cross_reference_not_visible_for?(current_user)
-
-- note_editable = note_editable?(note)
-%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} }
- .timeline-entry-inner
- .timeline-icon
- - if note.system
- = icon_for_system_note(note)
- - else
- %a{ href: user_path(note.author) }
- = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
- .timeline-content
- .note-header
- .note-header-info
- %a{ href: user_path(note.author) }
- %span.hidden-xs
- = sanitize(note.author.name)
- %span.note-headline-light
- = note.author.to_reference
- %span.note-headline-light
- %span.note-headline-meta
- - unless note.system
- commented
- - if note.system
- %span.system-note-message
- = note.redacted_note_html
- %a{ href: "##{dom_id(note)}" }
- = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- - unless note.system?
- .note-actions
- - access = note_max_access_for_user(note)
- - if access
- %span.note-role= access
-
- - if note.resolvable?
- - can_resolve = can?(current_user, :resolve_note, note)
- %resolve-btn{ "project-path" => project_path(note.project),
- "discussion-id" => note.discussion_id(@noteable),
- ":note-id" => note.id,
- ":resolved" => note.resolved?,
- ":can-resolve" => can_resolve,
- ":author-name" => "'#{j(note.author.name)}'",
- "author-avatar" => note.author.avatar_url,
- ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'",
- ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'",
- "v-show" => "#{can_resolve || note.resolved?}",
- "inline-template" => true,
- "ref" => "note_#{note.id}" }
-
- %button.note-action-button.line-resolve-btn{ type: "button",
- class: ("is-disabled" unless can_resolve),
- ":class" => "{ 'is-active': isResolved }",
- ":aria-label" => "buttonText",
- "@click" => "resolve",
- ":title" => "buttonText",
- ":ref" => "'button'" }
-
- = icon("spin spinner", "v-show" => "loading", class: 'loading')
- %div{ 'v-show' => '!loading' }= render "shared/icons/icon_status_success.svg"
-
- - if current_user
- - if note.emoji_awardable?
- - user_authored = note.user_authored?(current_user)
- = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do
- = icon('spinner spin')
- %span{ class: "link-highlight award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: "link-highlight award-control-icon-positive" }= custom_icon('emoji_smiley')
- %span{ class: "link-highlight award-control-icon-super-positive" }= custom_icon('emoji_smile')
-
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
- = icon('pencil', class: 'link-highlight')
- = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do
- = icon('trash-o', class: 'danger-highlight')
- .note-body{ class: note_editable ? 'js-task-list-container' : '' }
- .note-text.md
- = note.redacted_note_html
- = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true)
- - if note_editable
- .original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
- #{note.note}
- %textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note
- .note-awards
- = render 'award_emoji/awards_block', awardable: note, inline: false
- - if note.system
- .system-note-commit-list-toggler
- Toggle commit list
- %i.fa.fa-angle-down
- - if note.attachment.url
- .note-attachment
- - if note.attachment.image?
- = link_to note.attachment.url, target: '_blank' do
- = image_tag note.attachment.url, class: 'note-image-attach'
- .attachment
- = link_to note.attachment.url, target: '_blank' do
- = icon('paperclip')
- = note.attachment_identifier
- = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note),
- title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
- = icon('trash-o', class: 'cred')
diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml
index 90a150aa74c..555228623cc 100644
--- a/app/views/projects/notes/_notes_with_form.html.haml
+++ b/app/views/projects/notes/_notes_with_form.html.haml
@@ -1,5 +1,5 @@
%ul#notes-list.notes.main-notes-list.timeline
- = render "projects/notes/notes"
+ = render "shared/notes/notes"
= render 'projects/notes/edit_form'
diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml
deleted file mode 100644
index ad51fbc6cab..00000000000
--- a/app/views/projects/pages/_disabled.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.panel.panel-default
- .nothing-here-block
- GitLab Pages are disabled.
- Ask your system's administrator to enable it.
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 259d5bd63d6..b22a54d75c8 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -16,13 +16,10 @@
%hr.clearfix
-- if Gitlab.config.pages.enabled
- = render 'access'
- = render 'use'
- - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
- = render 'list'
- - else
- = render 'no_domains'
- = render 'destroy'
+= render 'access'
+= render 'use'
+- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+ = render 'list'
- else
- = render 'disabled'
+ = render 'no_domains'
+= render 'destroy'
diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml
index bc57f7f1c46..b0dac9de1c6 100644
--- a/app/views/projects/pipelines/_head.html.haml
+++ b/app/views/projects/pipelines/_head.html.haml
@@ -4,13 +4,13 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
- if project_nav_tab? :pipelines
- = nav_link(path: 'pipelines#index', controller: :pipelines) do
+ = nav_link(path: ['pipelines#index', 'pipelines#show']) do
= link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
%span
Pipelines
- if project_nav_tab? :builds
- = nav_link(controller: :builds) do
+ = nav_link(controller: [:builds, :artifacts]) do
= link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
%span
Jobs
diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml
index ab0771b5751..f83521052ed 100644
--- a/app/views/projects/project_members/_index.html.haml
+++ b/app/views/projects/project_members/_index.html.haml
@@ -6,6 +6,12 @@
%p
Add a new member to
%strong= @project.name
+ - else
+ %p
+ Members can be added by project
+ %i Masters
+ or
+ %i Owners
.col-lg-9
.light.prepend-top-default
- if can?(current_user, :admin_project_member, @project)
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index 81d57c77edf..7b1a26043e1 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -1,9 +1,11 @@
.panel.panel-default
- .panel-heading
- Members with access to
- %strong= @project.name
+ .panel-heading.flex-project-members-panel
+ %span.flex-project-title
+ Members of
+ %strong
+ #{@project.name}
%span.badge= @project_members.total_count
- = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do
+ = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
%button.member-search-btn{ type: "submit", "aria-label" => "Submit search" }
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index 6e187b54a59..af9a080f0a2 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -9,7 +9,7 @@
.form-group
= f.label :name, class: 'col-md-2 text-right' do
Tag:
- .col-md-10
+ .col-md-10.protected-tags-dropdown
= render partial: "projects/protected_tags/dropdown", locals: { f: f }
.help-block
= link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
index e50a543ffa8..8c7f9e0191e 100644
--- a/app/views/projects/settings/_head.html.haml
+++ b/app/views/projects/settings/_head.html.haml
@@ -14,7 +14,7 @@
%span
Members
- if can_edit
- = nav_link(controller: [:integrations, :services]) do
+ = nav_link(controller: [:integrations, :services, :hooks]) do
= link_to project_settings_integrations_path(@project), title: 'Integrations' do
%span
Integrations
@@ -27,7 +27,8 @@
= link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do
%span
CI/CD Pipelines
- = nav_link(controller: :pages) do
- = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do
- %span
- Pages
+ - if Gitlab.config.pages.enabled
+ = nav_link(controller: :pages) do
+ = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do
+ %span
+ Pages
diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml
index ceabe2eab3d..8dc276a3bec 100644
--- a/app/views/projects/settings/integrations/_project_hook.html.haml
+++ b/app/views/projects/settings/integrations/_project_hook.html.haml
@@ -9,6 +9,7 @@
.col-md-4.col-lg-5.text-right-lg.prepend-top-5
%span.append-right-10.inline
SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"}
+ = link_to "Edit", edit_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm"
= link_to "Test", test_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm"
= link_to namespace_project_hook_path(@project.namespace, @project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent" do
%span.sr-only Remove
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 5402320cb66..4e59033c4a3 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,6 +1,10 @@
- page_title "Repository"
= render "projects/settings/head"
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('deploy_keys')
+
= render @deploy_keys
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 7f9a44e565f..56656ea3d86 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -1,4 +1,5 @@
- @no_container = true
+- @sort ||= sort_value_recently_updated
- page_title "Tags"
= render "projects/commits/head"
@@ -14,16 +15,14 @@
.dropdown
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} }
%span.light
- = projects_sort_options_hash[@sort]
+ = tags_sort_options_hash[@sort]
= icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- %li
- = link_to filter_tags_path(sort: sort_value_name) do
- = sort_title_name
- = link_to filter_tags_path(sort: sort_value_recently_updated) do
- = sort_title_recently_updated
- = link_to filter_tags_path(sort: sort_value_oldest_updated) do
- = sort_title_oldest_updated
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ Sort by
+ - tags_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
- if can?(current_user, :push_code, @project)
= link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do
New tag
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index e7b3fe3ffda..396d1ecd77b 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -9,12 +9,9 @@
%li
= link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
= @project.path
- - tree_breadcrumbs(tree, 6) do |title, path|
+ - path_breadcrumbs do |title, path|
%li
- - if path
- = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path)
- - else
- = link_to title, '#'
+ = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path))
- if current_user
%li
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index b0778653d4e..07970ad9cba 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -11,8 +11,8 @@
= icon('caret-down')
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
- .arrow-up
- .js-builds-dropdown-list.scrollable-menu
+ %li.js-builds-dropdown-list.scrollable-menu
- .js-builds-dropdown-loading.builds-dropdown-loading.hidden
- %span.fa.fa-spinner.fa-spin
+ %li.js-builds-dropdown-loading.hidden
+ .text-center
+ %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index f1350169bbe..b6fce5e3cd4 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -117,7 +117,7 @@
.issues_bulk_update.hide
= form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
.filter-item.inline
- = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do
+ = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
%ul
%li
%a{ href: "#", data: { id: "reopen" } } Open
@@ -125,13 +125,13 @@
%a{ href: "#", data: { id: "close" } } Closed
.filter-item.inline
= dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
- placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } })
+ placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } })
.filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
+ = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } })
.filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
+ = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }
.filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do
+ = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
%ul
%li
%a{ href: "#", data: { id: "subscribe" } } Subscribe
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 2e0d6a129fb..bc638e994f3 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -48,7 +48,7 @@
.selectbox.hide-collapsed
= f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id'
- = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } })
+ = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } })
.block.milestone
.sidebar-collapsed-icon
@@ -136,7 +136,7 @@
- selected_labels.each do |label|
= hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } }
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 2793e7bcff4..f57b4d899ce 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -10,12 +10,16 @@
= form.label :source_branch, class: 'control-label'
.col-sm-10
.issuable-form-select-holder
- = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true })
+ = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true })
.form-group
= form.label :target_branch, class: 'control-label'
- .col-sm-10
+ .col-sm-10.target-branch-select-dropdown-container
.issuable-form-select-holder
- = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }})
+ = form.select(:target_branch, issuable.target_branches,
+ { include_blank: true },
+ { class: 'target_branch js-target-branch-select',
+ disabled: issuable.new_record?,
+ data: { placeholder: "Select branch" }})
- if issuable.new_record?
&nbsp;
= link_to 'Change branches', mr_change_branches_path(issuable)
diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml
new file mode 100644
index 00000000000..68458c2d0aa
--- /dev/null
+++ b/app/views/shared/milestones/_tab_loading.html.haml
@@ -0,0 +1,2 @@
+.text-center.prepend-top-default
+ = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content')
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 9a4502873ef..6a6d817b344 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,27 +1,27 @@
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs
+ %ul.nav-links.scrolling-tabs.js-milestone-tabs
- if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
%li.active
= link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do
Issues
%span.badge= milestone.issues_visible_to_user(current_user).size
%li
- = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do
+ = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
Merge Requests
%span.badge= milestone.merge_requests.size
- else
%li.active
- = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do
+ = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
Merge Requests
%span.badge= milestone.merge_requests.size
%li
- = link_to '#tab-participants', 'data-toggle' => 'tab' do
+ = link_to '#tab-participants', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do
Participants
%span.badge= milestone.participants.count
%li
- = link_to '#tab-labels', 'data-toggle' => 'tab' do
+ = link_to '#tab-labels', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do
Labels
%span.badge= milestone.labels.count
@@ -30,14 +30,18 @@
.tab-content.milestone-content
- if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
- .tab-pane.active#tab-issues
+ .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } }
= render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name
- .tab-pane#tab-merge-requests
- = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ .tab-pane#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } }
+ -# loaded async
+ = render "shared/milestones/tab_loading"
- else
- .tab-pane.active#tab-merge-requests
- = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ .tab-pane.active#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } }
+ -# loaded async
+ = render "shared/milestones/tab_loading"
.tab-pane#tab-participants
- = render 'shared/milestones/participants_tab', users: milestone.participants
+ -# loaded async
+ = render "shared/milestones/tab_loading"
.tab-pane#tab-labels
- = render 'shared/milestones/labels_tab', labels: milestone.labels
+ -# loaded async
+ = render "shared/milestones/tab_loading"
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
new file mode 100644
index 00000000000..9657b4eea82
--- /dev/null
+++ b/app/views/shared/notes/_note.html.haml
@@ -0,0 +1,66 @@
+- return unless note.author
+- return if note.cross_reference_not_visible_for?(current_user)
+
+- note_editable = note_editable?(note)
+%li.timeline-entry{ id: dom_id(note),
+ class: ["note", "note-row-#{note.id}", ('system-note' if note.system)],
+ data: { author_id: note.author.id,
+ editable: note_editable,
+ note_id: note.id } }
+ .timeline-entry-inner
+ .timeline-icon
+ - if note.system
+ = icon_for_system_note(note)
+ - else
+ %a{ href: user_path(note.author) }
+ = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
+ .timeline-content
+ .note-header
+ .note-header-info
+ %a{ href: user_path(note.author) }
+ %span.hidden-xs
+ = sanitize(note.author.name)
+ %span.note-headline-light
+ = note.author.to_reference
+ %span.note-headline-light
+ %span.note-headline-meta
+ - unless note.system
+ commented
+ - if note.system
+ %span.system-note-message
+ = note.redacted_note_html
+ %a{ href: "##{dom_id(note)}" }
+ = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
+ - unless note.system?
+ .note-actions
+ - if note.for_personal_snippet?
+ = render 'snippets/notes/actions', note: note, note_editable: note_editable
+ - else
+ = render 'projects/notes/actions', note: note, note_editable: note_editable
+ .note-body{ class: note_editable ? 'js-task-list-container' : '' }
+ .note-text.md
+ = note.redacted_note_html
+ = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true)
+ - if note_editable
+ - if note.for_personal_snippet?
+ = render 'snippets/notes/edit', note: note
+ - else
+ = render 'projects/notes/edit', note: note
+ .note-awards
+ = render 'award_emoji/awards_block', awardable: note, inline: false
+ - if note.system
+ .system-note-commit-list-toggler
+ Toggle commit list
+ %i.fa.fa-angle-down
+ - if note.attachment.url
+ .note-attachment
+ - if note.attachment.image?
+ = link_to note.attachment.url, target: '_blank' do
+ = image_tag note.attachment.url, class: 'note-image-attach'
+ .attachment
+ = link_to note.attachment.url, target: '_blank' do
+ = icon('paperclip')
+ = note.attachment_identifier
+ = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note),
+ title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
+ = icon('trash-o', class: 'cred')
diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/shared/notes/_notes.html.haml
index 2b2bab09c74..cfdfeeb9e97 100644
--- a/app/views/projects/notes/_notes.html.haml
+++ b/app/views/shared/notes/_notes.html.haml
@@ -1,8 +1,8 @@
- if defined?(@discussions)
- @discussions.each do |discussion|
- if discussion.individual_note?
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
+ = render partial: "shared/notes/note", collection: discussion.notes, as: :note
- else
= render 'discussions/discussion', discussion: discussion
- else
- = render partial: "projects/notes/note", collection: @notes, as: :note
+ = render partial: "shared/notes/note", collection: @notes, as: :note
diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml
index 67d186e2874..11f0fa7c49f 100644
--- a/app/views/shared/snippets/_blob.html.haml
+++ b/app/views/shared/snippets/_blob.html.haml
@@ -1,15 +1,6 @@
- blob = @snippet.blob
.js-file-title.file-title-flex-parent
- .file-header-content
- = blob_icon blob.mode, blob.path
-
- %strong.file-title-name
- = blob.path
-
- = copy_file_path_button(blob.path)
-
- %small
- = number_to_human_size(blob.raw_size)
+ = render 'projects/blob/header_content', blob: blob
.file-actions.hidden-xs
= render 'projects/blob/viewer_switcher', blob: blob
@@ -18,7 +9,6 @@
= copy_blob_source_button(blob)
= open_raw_blob_button(blob)
- - if defined?(download_path) && download_path
- = link_to icon('download'), download_path, class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' }
+ = link_to icon('download'), download_snippet_path(@snippet), target: '_blank', class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' }
= render 'projects/blob/content', blob: blob
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index ee3be3c789a..37c3e61912c 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -1,102 +1,82 @@
-.row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0
- = page_title
- %p
- #{link_to "Webhooks", help_page_path("user/project/integrations/webhooks")} can be
- used for binding events when something is happening within the project.
- .col-lg-9.append-bottom-default
- = form_for hook, as: :hook, url: polymorphic_path(url_components + [:hooks]) do |f|
- = form_errors(hook)
+= form_errors(hook)
- .form-group
- = f.label :url, "URL", class: 'label-light'
- = f.text_field :url, class: "form-control", placeholder: 'http://example.com/trigger-ci.json'
- .form-group
- = f.label :token, "Secret Token", class: 'label-light'
- = f.text_field :token, class: "form-control", placeholder: ''
- %p.help-block
- Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.
- .form-group
- = f.label :url, "Trigger", class: 'label-light'
- %ul.list-unstyled
- %li
- = f.check_box :push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :push_events, class: 'list-label' do
- %strong Push events
- %p.light
- This URL will be triggered by a push to the repository
- %li
- = f.check_box :tag_push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :tag_push_events, class: 'list-label' do
- %strong Tag push events
- %p.light
- This URL will be triggered when a new tag is pushed to the repository
- %li
- = f.check_box :note_events, class: 'pull-left'
- .prepend-left-20
- = f.label :note_events, class: 'list-label' do
- %strong Comments
- %p.light
- This URL will be triggered when someone adds a comment
- %li
- = f.check_box :issues_events, class: 'pull-left'
- .prepend-left-20
- = f.label :issues_events, class: 'list-label' do
- %strong Issues events
- %p.light
- This URL will be triggered when an issue is created/updated/merged
- %li
- = f.check_box :confidential_issues_events, class: 'pull-left'
- .prepend-left-20
- = f.label :confidential_issues_events, class: 'list-label' do
- %strong Confidential Issues events
- %p.light
- This URL will be triggered when a confidential issue is created/updated/merged
- %li
- = f.check_box :merge_requests_events, class: 'pull-left'
- .prepend-left-20
- = f.label :merge_requests_events, class: 'list-label' do
- %strong Merge Request events
- %p.light
- This URL will be triggered when a merge request is created/updated/merged
- %li
- = f.check_box :build_events, class: 'pull-left'
- .prepend-left-20
- = f.label :build_events, class: 'list-label' do
- %strong Jobs events
- %p.light
- This URL will be triggered when the job status changes
- %li
- = f.check_box :pipeline_events, class: 'pull-left'
- .prepend-left-20
- = f.label :pipeline_events, class: 'list-label' do
- %strong Pipeline events
- %p.light
- This URL will be triggered when the pipeline status changes
- %li
- = f.check_box :wiki_page_events, class: 'pull-left'
- .prepend-left-20
- = f.label :wiki_page_events, class: 'list-label' do
- %strong Wiki Page events
- %p.light
- This URL will be triggered when a wiki page is created/updated
- .form-group
- = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox'
- .checkbox
- = f.label :enable_ssl_verification do
- = f.check_box :enable_ssl_verification
- %strong Enable SSL verification
- = f.submit "Add webhook", class: "btn btn-create"
- %hr
- %h5.prepend-top-default
- Webhooks (#{hooks.count})
- - if hooks.any?
- %ul.well-list
- - hooks.each do |hook|
- = render "project_hook", hook: hook
- - else
- %p.settings-message.text-center.append-bottom-0
- No webhooks found, add one in the form above.
+.form-group
+ = form.label :url, 'URL', class: 'label-light'
+ = form.text_field :url, class: 'form-control', placeholder: 'http://example.com/trigger-ci.json'
+.form-group
+ = form.label :token, 'Secret Token', class: 'label-light'
+ = form.text_field :token, class: 'form-control', placeholder: ''
+ %p.help-block
+ Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.
+.form-group
+ = form.label :url, 'Trigger', class: 'label-light'
+ %ul.list-unstyled
+ %li
+ = form.check_box :push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :push_events, class: 'list-label' do
+ %strong Push events
+ %p.light
+ This URL will be triggered by a push to the repository
+ %li
+ = form.check_box :tag_push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :tag_push_events, class: 'list-label' do
+ %strong Tag push events
+ %p.light
+ This URL will be triggered when a new tag is pushed to the repository
+ %li
+ = form.check_box :note_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :note_events, class: 'list-label' do
+ %strong Comments
+ %p.light
+ This URL will be triggered when someone adds a comment
+ %li
+ = form.check_box :issues_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :issues_events, class: 'list-label' do
+ %strong Issues events
+ %p.light
+ This URL will be triggered when an issue is created/updated/merged
+ %li
+ = form.check_box :confidential_issues_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :confidential_issues_events, class: 'list-label' do
+ %strong Confidential Issues events
+ %p.light
+ This URL will be triggered when a confidential issue is created/updated/merged
+ %li
+ = form.check_box :merge_requests_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :merge_requests_events, class: 'list-label' do
+ %strong Merge Request events
+ %p.light
+ This URL will be triggered when a merge request is created/updated/merged
+ %li
+ = form.check_box :build_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :build_events, class: 'list-label' do
+ %strong Jobs events
+ %p.light
+ This URL will be triggered when the job status changes
+ %li
+ = form.check_box :pipeline_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :pipeline_events, class: 'list-label' do
+ %strong Pipeline events
+ %p.light
+ This URL will be triggered when the pipeline status changes
+ %li
+ = form.check_box :wiki_page_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :wiki_page_events, class: 'list-label' do
+ %strong Wiki Page events
+ %p.light
+ This URL will be triggered when a wiki page is created/updated
+.form-group
+ = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox'
+ .checkbox
+ = form.label :enable_ssl_verification do
+ = form.check_box :enable_ssl_verification
+ %strong Enable SSL verification
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
new file mode 100644
index 00000000000..679a5e934da
--- /dev/null
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -0,0 +1,13 @@
+- if current_user
+ - if note.emoji_awardable?
+ - user_authored = note.user_authored?(current_user)
+ = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
+ = icon('spinner spin')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
+ %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+ - if note_editable
+ = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
+ = icon('pencil', class: 'link-highlight')
+ = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
+ = icon('trash-o', class: 'danger-highlight')
diff --git a/app/views/snippets/notes/_edit.html.haml b/app/views/snippets/notes/_edit.html.haml
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/app/views/snippets/notes/_edit.html.haml
diff --git a/app/views/snippets/notes/_notes.html.haml b/app/views/snippets/notes/_notes.html.haml
new file mode 100644
index 00000000000..f07d6b8c126
--- /dev/null
+++ b/app/views/snippets/notes/_notes.html.haml
@@ -0,0 +1,2 @@
+%ul#notes-list.notes.main-notes-list.timeline
+ = render "projects/notes/notes"
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 8a80013bbfd..98287cba5b4 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -3,7 +3,10 @@
= render 'shared/snippets/header'
%article.file-holder.snippet-file-content
- = render 'shared/snippets/blob', download_path: download_snippet_path(@snippet)
+ = render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
+
+%ul#notes-list.notes.main-notes-list.timeline
+ #notes= render 'shared/notes/notes'
diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml
new file mode 100644
index 00000000000..0545cab538c
--- /dev/null
+++ b/app/views/users/_deletion_guidance.html.haml
@@ -0,0 +1,10 @@
+- user = local_assigns.fetch(:user)
+
+%ul
+ %li
+ %p
+ Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the
+ = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records")
+ - personal_projects_count = user.personal_projects.count
+ - unless personal_projects_count.zero?
+ %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored