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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGrzegorz Bizon <grzesiek.bizon@gmail.com>2017-05-05 13:31:10 +0300
committerGrzegorz Bizon <grzesiek.bizon@gmail.com>2017-05-05 13:31:10 +0300
commite944fd7064b5a04a334b6c500c9ee7db29a46538 (patch)
tree7418e54f3774ca676040a16a30a4a9cd7c922100 /app
parent3aa92cb5cbe8456c845e16d14489591dd81dbcb3 (diff)
parent3a2b60f7a0109cdb84e8727a2625318a746e84dc (diff)
Merge commit '3a2b60f7a' from 'master'
* commit '3a2b60f7a0109cdb84e8727a2625318a746e84dc': (254 commits) Fixed Karma spec Reject EE reserved namespace paths in CE as well Updated webpack config Include the bundler:audit job into the static-analysis job Document serializers Add artifact file page that uses the blob viewer Pipeline table mini graph dropdown remains open when table is refreshed Adds off for event hub Compile gitlab-shell go executables Allow to create new branch and empty WIP merge request from issue page Moved to a view spec Improving copy of CONTRIBUTING.md, PROCESS.md, and code_review.md Convert seconds to minutes and hours on chat notifations Disable navigation to Pages config if Pages is disabled Sort the network graph both by commit date and topographically. Add tooltips to note action buttons Add breadcrumb, build header and pipelines submenu to artifacts browser Update todos screenshots removes the possibility of commit messages having carriage returns Handle incoming emails from aliases correctly ...
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