summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/autosave.js44
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js1
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js17
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js2
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js114
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js6
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js10
-rw-r--r--app/assets/javascripts/blob/target_branch_dropdown.js4
-rw-r--r--app/assets/javascripts/blob/template_selector.js7
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js2
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js2
-rw-r--r--app/assets/javascripts/blob/template_selectors/gitignore_selector.js2
-rw-r--r--app/assets/javascripts/blob/template_selectors/license_selector.js13
-rw-r--r--app/assets/javascripts/blob/template_selectors/type_selector.js2
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js7
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js1
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js64
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js104
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js4
-rw-r--r--app/assets/javascripts/boards/models/assignee.js12
-rw-r--r--app/assets/javascripts/boards/models/issue.js34
-rw-r--r--app/assets/javascripts/boards/models/list.js5
-rw-r--r--app/assets/javascripts/boards/models/user.js12
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-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/cycle_analytics/components/limit_warning_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js3
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js17
-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.js6
-rw-r--r--app/assets/javascripts/droplab/constants.js3
-rw-r--r--app/assets/javascripts/droplab/drop_down.js2
-rw-r--r--app/assets/javascripts/droplab/utils.js16
-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/filtered_search/components/recent_searches_dropdown_content.js12
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js11
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js43
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js7
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.js14
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service_error.js11
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js169
-rw-r--r--app/assets/javascripts/gl_dropdown.js56
-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/issuable/issuable_bundle.js1
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js42
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js70
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js14
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/help_state.js25
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js12
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js14
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/time_tracker.js117
-rw-r--r--app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js66
-rw-r--r--app/assets/javascripts/issue.js74
-rw-r--r--app/assets/javascripts/issue_show/actions/tasks.js27
-rw-r--r--app/assets/javascripts/issue_show/index.js6
-rw-r--r--app/assets/javascripts/issue_show/issue_title.vue80
-rw-r--r--app/assets/javascripts/issue_show/issue_title_description.vue180
-rw-r--r--app/assets/javascripts/issue_status_select.js4
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js3
-rw-r--r--app/assets/javascripts/labels_select.js7
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js47
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js32
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js8
-rw-r--r--app/assets/javascripts/locale/de/app.js1
-rw-r--r--app/assets/javascripts/locale/en/app.js1
-rw-r--r--app/assets/javascripts/locale/es/app.js1
-rw-r--r--app/assets/javascripts/locale/index.js70
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/javascripts/members.js4
-rw-r--r--app/assets/javascripts/merge_request_widget.js2
-rw-r--r--app/assets/javascripts/milestone.js76
-rw-r--r--app/assets/javascripts/milestone_select.js5
-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/namespace_select.js3
-rw-r--r--app/assets/javascripts/new_branch_form.js16
-rw-r--r--app/assets/javascripts/notes.js556
-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/pipelines/services/pipelines_service.js2
-rw-r--r--app/assets/javascripts/preview_markdown.js48
-rw-r--r--app/assets/javascripts/project.js3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js3
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js4
-rw-r--r--app/assets/javascripts/raven/index.js16
-rw-r--r--app/assets/javascripts/raven/raven_config.js100
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js41
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js84
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js97
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js98
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js17
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js44
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js51
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/event_hub.js3
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js28
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js24
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js38
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js52
-rw-r--r--app/assets/javascripts/signin_tabs_memoizer.js12
-rw-r--r--app/assets/javascripts/subbable_resource.js51
-rw-r--r--app/assets/javascripts/subscription_select.js4
-rw-r--r--app/assets/javascripts/users/calendar.js26
-rw-r--r--app/assets/javascripts/users_select.js412
-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/javascripts/vue_shared/translate.js42
-rw-r--r--app/assets/stylesheets/framework/animations.scss28
-rw-r--r--app/assets/stylesheets/framework/avatar.scss11
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss19
-rw-r--r--app/assets/stylesheets/framework/files.scss12
-rw-r--r--app/assets/stylesheets/framework/filters.scss14
-rw-r--r--app/assets/stylesheets/framework/lists.scss1
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/pages/boards.scss74
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss31
-rw-r--r--app/assets/stylesheets/pages/diff.scss15
-rw-r--r--app/assets/stylesheets/pages/environments.scss29
-rw-r--r--app/assets/stylesheets/pages/issuable.scss128
-rw-r--r--app/assets/stylesheets/pages/issues.scss92
-rw-r--r--app/assets/stylesheets/pages/labels.scss2
-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.scss41
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss77
-rw-r--r--app/assets/stylesheets/pages/todos.scss3
-rw-r--r--app/assets/stylesheets/pages/wiki.scss1
-rw-r--r--app/controllers/admin/application_settings_controller.rb2
-rw-r--r--app/controllers/admin/services_controller.rb2
-rw-r--r--app/controllers/application_controller.rb12
-rw-r--r--app/controllers/concerns/issuable_actions.rb1
-rw-r--r--app/controllers/concerns/issuable_collections.rb2
-rw-r--r--app/controllers/concerns/markdown_preview.rb19
-rw-r--r--app/controllers/concerns/milestone_actions.rb53
-rw-r--r--app/controllers/concerns/notes_actions.rb44
-rw-r--r--app/controllers/concerns/routable_actions.rb38
-rw-r--r--app/controllers/concerns/uploads_actions.rb27
-rw-r--r--app/controllers/dashboard/labels_controller.rb2
-rw-r--r--app/controllers/groups/application_controller.rb24
-rw-r--r--app/controllers/groups/labels_controller.rb2
-rw-r--r--app/controllers/groups/milestones_controller.rb4
-rw-r--r--app/controllers/groups_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb3
-rw-r--r--app/controllers/projects/application_controller.rb53
-rw-r--r--app/controllers/projects/artifacts_controller.rb36
-rw-r--r--app/controllers/projects/boards/issues_controller.rb2
-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/git_http_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb38
-rw-r--r--app/controllers/projects/labels_controller.rb2
-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.rb44
-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.rb72
-rw-r--r--app/controllers/projects/raw_controller.rb2
-rw-r--r--app/controllers/projects/uploads_controller.rb32
-rw-r--r--app/controllers/projects/wikis_controller.rb13
-rw-r--r--app/controllers/projects_controller.rb11
-rw-r--r--app/controllers/snippets/notes_controller.rb9
-rw-r--r--app/controllers/snippets_controller.rb11
-rw-r--r--app/controllers/uploads_controller.rb82
-rw-r--r--app/controllers/users_controller.rb9
-rw-r--r--app/finders/issuable_finder.rb2
-rw-r--r--app/finders/issues_finder.rb19
-rw-r--r--app/finders/pipelines_finder.rb108
-rw-r--r--app/helpers/application_helper.rb16
-rw-r--r--app/helpers/blob_helper.rb30
-rw-r--r--app/helpers/boards_helper.rb1
-rw-r--r--app/helpers/builds_helper.rb12
-rw-r--r--app/helpers/form_helper.rb32
-rw-r--r--app/helpers/gitlab_routing_helper.rb10
-rw-r--r--app/helpers/issuables_helper.rb10
-rw-r--r--app/helpers/milestones_helper.rb24
-rw-r--r--app/helpers/notes_helper.rb67
-rw-r--r--app/helpers/sorting_helper.rb8
-rw-r--r--app/helpers/system_note_helper.rb1
-rw-r--r--app/helpers/tree_helper.rb4
-rw-r--r--app/mailers/emails/issues.rb6
-rw-r--r--app/models/application_setting.rb4
-rw-r--r--app/models/blob.rb47
-rw-r--r--app/models/blob_viewer/balsamiq.rb12
-rw-r--r--app/models/blob_viewer/base.rb11
-rw-r--r--app/models/ci/artifact_blob.rb35
-rw-r--r--app/models/ci/group.rb40
-rw-r--r--app/models/ci/stage.rb8
-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/issuable.rb29
-rw-r--r--app/models/concerns/milestoneish.rb2
-rw-r--r--app/models/concerns/note_on_diff.rb4
-rw-r--r--app/models/concerns/routable.rb24
-rw-r--r--app/models/diff_discussion.rb20
-rw-r--r--app/models/diff_note.rb7
-rw-r--r--app/models/event.rb6
-rw-r--r--app/models/global_milestone.rb6
-rw-r--r--app/models/issue.rb43
-rw-r--r--app/models/issue_assignee.rb13
-rw-r--r--app/models/legacy_diff_discussion.rb18
-rw-r--r--app/models/merge_request.rb46
-rw-r--r--app/models/milestone.rb5
-rw-r--r--app/models/namespace.rb6
-rw-r--r--app/models/note.rb28
-rw-r--r--app/models/project.rb27
-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/project_wiki.rb2
-rw-r--r--app/models/redirect_route.rb12
-rw-r--r--app/models/repository.rb42
-rw-r--r--app/models/route.rb55
-rw-r--r--app/models/snippet.rb5
-rw-r--r--app/models/snippet_blob.rb30
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/user.rb16
-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/analytics_stage_entity.rb1
-rw-r--r--app/serializers/analytics_summary_entity.rb5
-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/issuable_entity.rb1
-rw-r--r--app/serializers/issue_entity.rb1
-rw-r--r--app/serializers/job_group_entity.rb16
-rw-r--r--app/serializers/label_entity.rb1
-rw-r--r--app/serializers/label_serializer.rb7
-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/merge_request_entity.rb1
-rw-r--r--app/serializers/project_entity.rb14
-rw-r--r--app/serializers/stage_entity.rb8
-rw-r--r--app/serializers/status_entity.rb7
-rw-r--r--app/services/issuable/bulk_update_service.rb6
-rw-r--r--app/services/issuable_base_service.rb39
-rw-r--r--app/services/issues/base_service.rb22
-rw-r--r--app/services/issues/update_service.rb14
-rw-r--r--app/services/members/authorized_destroy_service.rb15
-rw-r--r--app/services/merge_requests/assign_issues_service.rb4
-rw-r--r--app/services/merge_requests/base_service.rb5
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb54
-rw-r--r--app/services/merge_requests/update_service.rb5
-rw-r--r--app/services/notes/build_service.rb18
-rw-r--r--app/services/notification_recipient_service.rb7
-rw-r--r--app/services/notification_service.rb29
-rw-r--r--app/services/preview_markdown_service.rb45
-rw-r--r--app/services/projects/enable_deploy_key_service.rb5
-rw-r--r--app/services/projects/propagate_service_template.rb103
-rw-r--r--app/services/projects/upload_service.rb22
-rw-r--r--app/services/slash_commands/interpret_service.rb231
-rw-r--r--app/services/system_note_service.rb55
-rw-r--r--app/services/todo_service.rb4
-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/application_settings/_form.html.haml18
-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.haml1
-rw-r--r--app/views/errors/omniauth_error.html.haml21
-rw-r--r--app/views/groups/milestones/new.html.haml2
-rw-r--r--app/views/issues/_issue.atom.builder15
-rw-r--r--app/views/layouts/_head.html.haml3
-rw-r--r--app/views/layouts/application.html.haml4
-rw-r--r--app/views/layouts/devise.html.haml1
-rw-r--r--app/views/layouts/devise_empty.html.haml1
-rw-r--r--app/views/layouts/nav/_project.html.haml2
-rw-r--r--app/views/layouts/oauth_error.html.haml127
-rw-r--r--app/views/layouts/project.html.haml7
-rw-r--r--app/views/layouts/snippets.html.haml6
-rw-r--r--app/views/notify/_reassigned_issuable_email.text.erb6
-rw-r--r--app/views/notify/new_issue_email.html.haml4
-rw-r--r--app/views/notify/new_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb2
-rw-r--r--app/views/notify/reassigned_issue_email.html.haml11
-rw-r--r--app/views/notify/reassigned_issue_email.text.erb7
-rw-r--r--app/views/notify/reassigned_merge_request_email.html.haml10
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb7
-rw-r--r--app/views/profiles/accounts/show.html.haml6
-rw-r--r--app/views/profiles/show.html.haml5
-rw-r--r--app/views/projects/_md_preview.html.haml7
-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/blob/viewers/_balsamiq.html.haml4
-rw-r--r--app/views/projects/boards/components/_board.html.haml4
-rw-r--r--app/views/projects/boards/components/sidebar/_assignee.html.haml45
-rw-r--r--app/views/projects/branches/new.html.haml12
-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.haml6
-rw-r--r--app/views/projects/compare/_form.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/_empty_stage.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/_no_access.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml53
-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/environments/metrics.html.haml2
-rw-r--r--app/views/projects/group_links/_index.html.haml4
-rw-r--r--app/views/projects/issues/_discussion.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/_new_branch.html.haml36
-rw-r--r--app/views/projects/issues/show.html.haml18
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml2
-rw-r--r--app/views/projects/merge_requests/_show.html.haml2
-rw-r--r--app/views/projects/merge_requests/show/_versions.html.haml14
-rw-r--r--app/views/projects/milestones/_form.html.haml4
-rw-r--r--app/views/projects/notes/_actions.html.haml6
-rw-r--r--app/views/projects/notes/_edit.html.haml3
-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/pipelines/_with_tabs.html.haml19
-rw-r--r--app/views/projects/project_members/_index.html.haml8
-rw-r--r--app/views/projects/project_members/_team.html.haml10
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_branches/_update_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_tags/_update_protected_tag.haml2
-rw-r--r--app/views/projects/releases/edit.html.haml4
-rw-r--r--app/views/projects/settings/_head.html.haml9
-rw-r--r--app/views/projects/settings/repository/show.html.haml4
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/tags/index.html.haml17
-rw-r--r--app/views/projects/tags/new.html.haml4
-rw-r--r--app/views/projects/tree/_tree_header.html.haml7
-rw-r--r--app/views/projects/wikis/_form.html.haml4
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml8
-rw-r--r--app/views/shared/_ref_dropdown.html.haml (renamed from app/views/projects/compare/_ref_dropdown.html.haml)4
-rw-r--r--app/views/shared/errors/_graphic_422.svg1
-rw-r--r--app/views/shared/issuable/_assignees.html.haml15
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_participants.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml15
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml97
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml10
-rw-r--r--app/views/shared/issuable/form/_description.html.haml13
-rw-r--r--app/views/shared/issuable/form/_issue_assignee.html.haml30
-rw-r--r--app/views/shared/issuable/form/_merge_request_assignee.html.haml31
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml28
-rw-r--r--app/views/shared/members/_requests.html.haml2
-rw-r--r--app/views/shared/milestones/_issuable.html.haml8
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml14
-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/_comment_button.html.haml (renamed from app/views/projects/notes/_comment_button.html.haml)0
-rw-r--r--app/views/shared/notes/_edit.html.haml3
-rw-r--r--app/views/shared/notes/_edit_form.html.haml (renamed from app/views/projects/notes/_edit_form.html.haml)8
-rw-r--r--app/views/shared/notes/_form.html.haml (renamed from app/views/projects/notes/_form.html.haml)12
-rw-r--r--app/views/shared/notes/_hints.html.haml (renamed from app/views/projects/notes/_hints.html.haml)0
-rw-r--r--app/views/shared/notes/_note.html.haml13
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml (renamed from app/views/projects/notes/_notes_with_form.html.haml)8
-rw-r--r--app/views/shared/snippets/_blob.html.haml11
-rw-r--r--app/views/shared/snippets/_header.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml6
-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.haml12
-rw-r--r--app/views/users/_deletion_guidance.html.haml10
-rw-r--r--app/workers/post_receive.rb38
-rw-r--r--app/workers/propagate_service_template_worker.rb21
414 files changed, 8197 insertions, 2505 deletions
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 8630b18a73f..cfab6c40b34 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,8 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */
+import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
function Autosave(field, key) {
this.field = field;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
if (key.join != null) {
key = key.join("/");
}
@@ -17,16 +20,12 @@ window.Autosave = (function() {
}
Autosave.prototype.restore = function() {
- var e, text;
- if (window.localStorage == null) {
- return;
- }
- try {
- text = window.localStorage.getItem(this.key);
- } catch (error) {
- e = error;
- return;
- }
+ var text;
+
+ if (!this.isLocalStorageAvailable) return;
+
+ text = window.localStorage.getItem(this.key);
+
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
@@ -35,27 +34,22 @@ window.Autosave = (function() {
Autosave.prototype.save = function() {
var text;
- if (window.localStorage == null) {
- return;
- }
text = this.field.val();
- if ((text != null ? text.length : void 0) > 0) {
- try {
- return window.localStorage.setItem(this.key, text);
- } catch (error) {}
- } else {
- return this.reset();
+
+ if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
+ return window.localStorage.setItem(this.key, text);
}
+
+ return this.reset();
};
Autosave.prototype.reset = function() {
- if (window.localStorage == null) {
- return;
- }
- try {
- return window.localStorage.removeItem(this.key);
- } catch (error) {}
+ if (!this.isLocalStorageAvailable) return;
+
+ return window.localStorage.removeItem(this.key);
};
return Autosave;
})();
+
+export default window.Autosave;
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/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
index aa522e20c36..257df55e54f 100644
--- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
+++ b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
@@ -1,3 +1,5 @@
+import AccessorUtilities from '../../lib/utils/accessor';
+
const unicodeSupportTestMap = {
// man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
// occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
@@ -140,16 +142,25 @@ function generateUnicodeSupportMap(testMap) {
function getUnicodeSupportMap() {
let unicodeSupportMap;
- const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+ let userAgentFromCache;
+
+ const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+
try {
unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
} catch (err) {
// swallow
}
+
if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) {
unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap);
- window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
- window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
+
+ if (isLocalStorageAvailable) {
+ window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
+ window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
+ }
}
return unicodeSupportMap;
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 3d162b24413..1f9e0448084 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
const $submitButton = $form.find('input[type=submit], button[type=submit]');
if (!$submitButton.attr('disabled')) {
+ $submitButton.trigger('click', [e]);
$submitButton.disable();
- $form.submit();
}
});
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
new file mode 100644
index 00000000000..cdbfe36ca1c
--- /dev/null
+++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
@@ -0,0 +1,114 @@
+/* global Flash */
+
+import sqljs from 'sql.js';
+import { template as _template } from 'underscore';
+
+const PREVIEW_TEMPLATE = _template(`
+ <div class="panel panel-default">
+ <div class="panel-heading"><%- name %></div>
+ <div class="panel-body">
+ <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
+ </div>
+ </div>
+`);
+
+class BalsamiqViewer {
+ constructor(viewer) {
+ this.viewer = viewer;
+ this.endpoint = this.viewer.dataset.endpoint;
+ }
+
+ loadFile() {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open('GET', this.endpoint, true);
+ xhr.responseType = 'arraybuffer';
+
+ xhr.onload = this.renderFile.bind(this);
+ xhr.onerror = BalsamiqViewer.onError;
+
+ xhr.send();
+ }
+
+ renderFile(loadEvent) {
+ const container = document.createElement('ul');
+
+ this.initDatabase(loadEvent.target.response);
+
+ const previews = this.getPreviews();
+ previews.forEach((preview) => {
+ const renderedPreview = this.renderPreview(preview);
+
+ container.appendChild(renderedPreview);
+ });
+
+ container.classList.add('list-inline');
+ container.classList.add('previews');
+
+ this.viewer.appendChild(container);
+ }
+
+ initDatabase(data) {
+ const previewBinary = new Uint8Array(data);
+
+ this.database = new sqljs.Database(previewBinary);
+ }
+
+ getPreviews() {
+ const thumbnails = this.database.exec('SELECT * FROM thumbnails');
+
+ return thumbnails[0].values.map(BalsamiqViewer.parsePreview);
+ }
+
+ getResource(resourceID) {
+ const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`);
+
+ return resources[0];
+ }
+
+ renderPreview(preview) {
+ const previewElement = document.createElement('li');
+
+ previewElement.classList.add('preview');
+ previewElement.innerHTML = this.renderTemplate(preview);
+
+ return previewElement;
+ }
+
+ renderTemplate(preview) {
+ const resource = this.getResource(preview.resourceID);
+ const name = BalsamiqViewer.parseTitle(resource);
+ const image = preview.image;
+
+ const template = PREVIEW_TEMPLATE({
+ name,
+ image,
+ });
+
+ return template;
+ }
+
+ static parsePreview(preview) {
+ return JSON.parse(preview[1]);
+ }
+
+ /*
+ * resource = {
+ * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'],
+ * values: [['id', 'branchId', 'attributes', 'data']],
+ * }
+ *
+ * 'attributes' being a JSON string containing the `name` property.
+ */
+ static parseTitle(resource) {
+ return JSON.parse(resource.values[0][2]).name;
+ }
+
+ static onError() {
+ const flash = new Flash('Balsamiq file could not be loaded.');
+
+ return flash;
+ }
+}
+
+export default BalsamiqViewer;
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
new file mode 100644
index 00000000000..1dacf84470f
--- /dev/null
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -0,0 +1,6 @@
+import BalsamiqViewer from './balsamiq/balsamiq_viewer';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer'));
+ balsamiqViewer.loadFile();
+});
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 3062cd51ee3..a20c6ca7a21 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -99,7 +99,7 @@ export default class FileTemplateMediator {
});
}
- selectTemplateType(item, el, e) {
+ selectTemplateType(item, e) {
if (e) {
e.preventDefault();
}
@@ -117,6 +117,10 @@ export default class FileTemplateMediator {
this.cacheToggleText();
}
+ selectTemplateTypeOptions(options) {
+ this.selectTemplateType(options.selectedObj, options.e);
+ }
+
selectTemplateFile(selector, query, data) {
selector.renderLoading();
// in case undo menu is already already there
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index 31dd45fac89..ab5b3751c4e 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -52,9 +52,17 @@ export default class FileTemplateSelector {
.removeClass('fa-spinner fa-spin');
}
- reportSelection(query, el, e, data) {
+ reportSelection(options) {
+ const { query, e, data } = options;
e.preventDefault();
return this.mediator.selectTemplateFile(this, query, data);
}
+
+ reportSelectionName(options) {
+ const opts = options;
+ opts.query = options.selectedObj.name;
+
+ this.reportSelection(opts);
+ }
}
diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js
index 216f069ef71..d52d69b1274 100644
--- a/app/assets/javascripts/blob/target_branch_dropdown.js
+++ b/app/assets/javascripts/blob/target_branch_dropdown.js
@@ -37,8 +37,8 @@ class TargetBranchDropDown {
}
return SELECT_ITEM_MSG;
},
- clicked(item, el, e) {
- e.preventDefault();
+ clicked(options) {
+ options.e.preventDefault();
self.onClick.call(self);
},
fieldName: self.fieldName,
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index d7c1c32efbd..888883163c5 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -24,7 +24,7 @@ export default class TemplateSelector {
search: {
fields: ['name'],
},
- clicked: (item, el, e) => this.fetchFileTemplate(item, el, e),
+ clicked: options => this.fetchFileTemplate(options),
text: item => item.name,
});
}
@@ -51,7 +51,10 @@ export default class TemplateSelector {
return this.$dropdownContainer.removeClass('hidden');
}
- fetchFileTemplate(item, el, e) {
+ fetchFileTemplate(options) {
+ const { e } = options;
+ const item = options.selectedObj;
+
e.preventDefault();
return this.requestFile(item);
}
diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
index 935df07677c..f2f81af137b 100644
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
@@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index b4b4d09c315..3cb7b960aaa 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
index aefae54ae71..7efda8e7f50 100644
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
@@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
index c8abd689ab4..1d757332f6c 100644
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/license_selector.js
@@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => {
+ clicked: (options) => {
+ const { e } = options;
+ const el = options.$el;
+ const query = options.selectedObj;
+
const data = {
project: this.$dropdown.data('project'),
fullname: this.$dropdown.data('fullname'),
};
- this.reportSelection(query.id, el, e, data);
+ this.reportSelection({
+ query: query.id,
+ el,
+ e,
+ data,
+ });
},
text: item => item.name,
});
diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js
index 56f23ef0568..a09381014a7 100644
--- a/app/assets/javascripts/blob/template_selectors/type_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/type_selector.js
@@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
filterable: false,
selectable: true,
toggleLabel: item => item.name,
- clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e),
+ clicked: options => this.mediator.selectTemplateTypeOptions(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index b6dee8177d2..88eb4251339 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -11,7 +11,7 @@ require('./models/issue');
require('./models/label');
require('./models/list');
require('./models/milestone');
-require('./models/user');
+require('./models/assignee');
require('./stores/boards_store');
require('./stores/modal_store');
require('./services/board_service');
@@ -59,7 +59,8 @@ $(() => {
issueLinkBase: $boardApp.dataset.issueLinkBase,
rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: Store.detail
+ detailIssue: Store.detail,
+ defaultAvatar: $boardApp.dataset.defaultAvatar,
},
computed: {
detailIssueVisible () {
@@ -82,7 +83,7 @@ $(() => {
gl.boardService.all()
.then((resp) => {
resp.json().forEach((board) => {
- const list = Store.addList(board);
+ const list = Store.addList(board, this.defaultAvatar);
if (list.type === 'closed') {
list.position = Infinity;
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 0fa85b6fe14..1ce95b62138 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -26,6 +26,7 @@ export default {
title: this.title,
labels,
subscribed: true,
+ assignees: [],
});
this.list.newIssue(issue)
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 004bac09f59..317cef9f227 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -3,8 +3,13 @@
/* global MilestoneSelect */
/* global LabelsSelect */
/* global Sidebar */
+/* global Flash */
import Vue from 'vue';
+import eventHub from '../../sidebar/event_hub';
+
+import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
+import Assignees from '../../sidebar/components/assignees/assignees';
require('./sidebar/remove_issue');
@@ -22,11 +27,15 @@ gl.issueBoards.BoardSidebar = Vue.extend({
detail: Store.detail,
issue: {},
list: {},
+ loadingAssignees: false,
};
},
computed: {
showSidebar () {
return Object.keys(this.issue).length;
+ },
+ assigneeId() {
+ return this.issue.assignee ? this.issue.assignee.id : 0;
}
},
watch: {
@@ -40,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue;
this.list = this.detail.list;
+
+ this.$nextTick(() => {
+ this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
+ });
},
deep: true
},
@@ -50,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({
$('.right-sidebar').getNiceScroll().resize();
});
}
- }
+
+ this.issue = this.detail.issue;
+ this.list = this.detail.list;
+ },
+ deep: true
},
methods: {
closeSidebar () {
this.detail.issue = {};
- }
+ },
+ assignSelf () {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
+
+ this.addAssignee(this.currentUser);
+ this.saveAssignees();
+ },
+ removeAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
+ },
+ addAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
+ },
+ removeAllAssignees () {
+ gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
+ },
+ saveAssignees () {
+ this.loadingAssignees = true;
+
+ gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
+ .then(() => {
+ this.loadingAssignees = false;
+ })
+ .catch(() => {
+ this.loadingAssignees = false;
+ return new Flash('An error occurred while saving assignees');
+ });
+ },
+ },
+ created () {
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
},
mounted () {
new IssuableContext(this.currentUser);
@@ -67,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
components: {
removeBtn: gl.issueBoards.RemoveIssueBtn,
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index fc154ee7b8b..710207db0c7 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({
default: false,
},
},
+ data() {
+ return {
+ limitBeforeCounter: 3,
+ maxRender: 4,
+ maxCounter: 99,
+ };
+ },
computed: {
- cardUrl() {
- return `${this.issueLinkBase}/${this.issue.id}`;
+ numberOverLimit() {
+ return this.issue.assignees.length - this.limitBeforeCounter;
},
- assigneeUrl() {
- return `${this.rootPath}${this.issue.assignee.username}`;
+ assigneeCounterTooltip() {
+ return `${this.assigneeCounterLabel} more`;
+ },
+ assigneeCounterLabel() {
+ if (this.numberOverLimit > this.maxCounter) {
+ return `${this.maxCounter}+`;
+ }
+
+ return `+${this.numberOverLimit}`;
},
- assigneeUrlTitle() {
- return `Assigned to ${this.issue.assignee.name}`;
+ shouldRenderCounter() {
+ if (this.issue.assignees.length <= this.maxRender) {
+ return false;
+ }
+
+ return this.issue.assignees.length > this.numberOverLimit;
},
- avatarUrlTitle() {
- return `Avatar for ${this.issue.assignee.name}`;
+ cardUrl() {
+ return `${this.issueLinkBase}/${this.issue.id}`;
},
issueId() {
return `#${this.issue.id}`;
@@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
},
},
methods: {
+ isIndexLessThanlimit(index) {
+ return index < this.limitBeforeCounter;
+ },
+ shouldRenderAssignee(index) {
+ // Eg. maxRender is 4,
+ // Render up to all 4 assignees if there are only 4 assigness
+ // Otherwise render up to the limitBeforeCounter
+ if (this.issue.assignees.length <= this.maxRender) {
+ return index < this.maxRender;
+ }
+
+ return index < this.limitBeforeCounter;
+ },
+ assigneeUrl(assignee) {
+ return `${this.rootPath}${assignee.username}`;
+ },
+ assigneeUrlTitle(assignee) {
+ return `Assigned to ${assignee.name}`;
+ },
+ avatarUrlTitle(assignee) {
+ return `Avatar for ${assignee.name}`;
+ },
showLabel(label) {
if (!this.list) return true;
@@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
{{ issueId }}
</span>
</h4>
- <a
- class="card-assignee has-tooltip js-no-trigger"
- :href="assigneeUrl"
- :title="assigneeUrlTitle"
- v-if="issue.assignee"
- data-container="body"
- >
- <img
- class="avatar avatar-inline s20 js-no-trigger"
- :src="issue.assignee.avatar"
- width="20"
- height="20"
- :alt="avatarUrlTitle"
- />
- </a>
+ <div class="card-assignee">
+ <a
+ class="has-tooltip js-no-trigger"
+ :href="assigneeUrl(assignee)"
+ :title="assigneeUrlTitle(assignee)"
+ v-for="(assignee, index) in issue.assignees"
+ v-if="shouldRenderAssignee(index)"
+ data-container="body"
+ data-placement="bottom"
+ >
+ <img
+ class="avatar avatar-inline s20"
+ :src="assignee.avatar"
+ width="20"
+ height="20"
+ :alt="avatarUrlTitle(assignee)"
+ />
+ </a>
+ <span
+ class="avatar-counter has-tooltip"
+ :title="assigneeCounterTooltip"
+ v-if="shouldRenderCounter"
+ >
+ {{ assigneeCounterLabel }}
+ </span>
+ </div>
</div>
- <div class="card-footer" v-if="showLabelFooter">
+ <div
+ class="card-footer"
+ v-if="showLabelFooter"
+ >
<button
- class="label color-label has-tooltip js-no-trigger"
+ class="label color-label has-tooltip"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 7e3bb79af1d..f29b6caa1ac 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true,
selectable: true,
multiSelect: true,
- clicked (label, $el, e) {
+ clicked (options) {
+ const { e } = options;
+ const label = options.selectedObj;
e.preventDefault();
if (!Store.findList('title', label.title)) {
diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
new file mode 100644
index 00000000000..05dd449e4fd
--- /dev/null
+++ b/app/assets/javascripts/boards/models/assignee.js
@@ -0,0 +1,12 @@
+/* eslint-disable no-unused-vars */
+
+class ListAssignee {
+ constructor(user, defaultAvatar) {
+ this.id = user.id;
+ this.name = user.name;
+ this.username = user.username;
+ this.avatar = user.avatar_url || defaultAvatar;
+ }
+}
+
+window.ListAssignee = ListAssignee;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index d6175069e37..6c2d8a3781b 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -1,12 +1,12 @@
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
/* global ListLabel */
/* global ListMilestone */
-/* global ListUser */
+/* global ListAssignee */
import Vue from 'vue';
class ListIssue {
- constructor (obj) {
+ constructor (obj, defaultAvatar) {
this.globalId = obj.id;
this.id = obj.iid;
this.title = obj.title;
@@ -14,14 +14,10 @@ class ListIssue {
this.dueDate = obj.due_date;
this.subscribed = obj.subscribed;
this.labels = [];
+ this.assignees = [];
this.selected = false;
- this.assignee = false;
this.position = obj.relative_position || Infinity;
- if (obj.assignee) {
- this.assignee = new ListUser(obj.assignee);
- }
-
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
}
@@ -29,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label));
});
+
+ this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar));
}
addLabel (label) {
@@ -51,6 +49,26 @@ class ListIssue {
labels.forEach(this.removeLabel.bind(this));
}
+ addAssignee (assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(new ListAssignee(assignee));
+ }
+ }
+
+ findAssignee (findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee (removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees () {
+ this.assignees = [];
+ }
+
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
}
@@ -60,7 +78,7 @@ class ListIssue {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate,
- assignee_id: this.assignee ? this.assignee.id : null,
+ assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
label_ids: this.labels.map((label) => label.id)
}
};
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index f2b79a88a4a..bd2f62bcc1a 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -6,7 +6,7 @@ import queryData from '../utils/query_data';
const PER_PAGE = 20;
class List {
- constructor (obj) {
+ constructor (obj, defaultAvatar) {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
@@ -18,6 +18,7 @@ class List {
this.loadingMore = false;
this.issues = [];
this.issuesSize = 0;
+ this.defaultAvatar = defaultAvatar;
if (obj.label) {
this.label = new ListLabel(obj.label);
@@ -106,7 +107,7 @@ class List {
createIssues (data) {
data.forEach((issueObj) => {
- this.addIssue(new ListIssue(issueObj));
+ this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
});
}
diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js
deleted file mode 100644
index 8e9de4d4cbb..00000000000
--- a/app/assets/javascripts/boards/models/user.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/* eslint-disable no-unused-vars */
-
-class ListUser {
- constructor(user) {
- this.id = user.id;
- this.name = user.name;
- this.username = user.username;
- this.avatar = user.avatar_url;
- }
-}
-
-window.ListUser = ListUser;
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index ccb00099215..ad9997ac334 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -23,8 +23,8 @@ gl.issueBoards.BoardsStore = {
this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&');
},
- addList (listObj) {
- const list = new List(listObj);
+ addList (listObj, defaultAvatar) {
+ const list = new List(listObj, defaultAvatar);
this.state.lists.push(list);
return list;
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/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
index abe48572347..8d3d34f836f 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
@@ -9,9 +9,9 @@ export default {
<span v-if="count === 50" class="events-info pull-right">
<i class="fa fa-warning has-tooltip"
aria-hidden="true"
- title="Limited to showing 50 events at most"
+ :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)"
data-placement="top"></i>
- Showing 50 events
+ {{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
`,
};
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
index 80bd2df6f42..0d9ad197abf 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
@@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
- Opened
+ {{ __('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
- by
+ {{ __('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
</div>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
index 20a43798fbe..ad285874643 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
@@ -28,11 +28,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
- Opened
+ {{ __('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
- by
+ {{ __('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
index f33cac3da82..222084deee9 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
@@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
</a>
</h5>
<span>
- First
+ {{ __('FirstPushedBy|First') }}
<span class="commit-icon">${iconCommit}</span>
<a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
- pushed by
+ {{ __('FirstPushedBy|pushed by') }}
<a :href="commit.author.webUrl" class="commit-author-link">
{{ commit.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
index 657f5385374..a14ebc3ece9 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
@@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
- Opened
+ {{ __('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
- by
+ {{ __('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
index 8a801300647..1a5bf9bc0b5 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
@@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
- Opened
+ {{ __('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
- by
+ {{ __('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
<template v-if="mergeRequest.state === 'closed'">
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
index 4a286379588..b1e9362434f 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
@@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
</h5>
<span>
<a :href="build.url" class="build-date">{{ build.date }}</a>
- by
+ {{ __('ByAuthor|by') }}
<a :href="build.author.webUrl" class="issue-author-link">
{{ build.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
index 77edcb76273..d5e6167b2a8 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
@@ -12,10 +12,10 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
template: `
<span class="total-time">
<template v-if="Object.keys(time).length">
- <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
- <template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
- <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
- <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
+ <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template>
+ <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template>
+ <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template>
+ <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template>
</template>
<template v-else>
--
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 48cab437e02..c8e53cb554e 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -2,6 +2,7 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
+import Translate from '../vue_shared/translate';
import LimitWarningComponent from './components/limit_warning_component';
require('./components/stage_code_component');
@@ -16,6 +17,8 @@ require('./cycle_analytics_service');
require('./cycle_analytics_store');
require('./default_event_objects');
+Vue.use(Translate);
+
$(() => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
index 681d6eef565..6504d7db2f2 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
@@ -30,7 +30,7 @@ class CycleAnalyticsService {
startDate,
} = options;
- return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
+ return $.get(`${this.requestPath}/events/${stage.name}.json`, {
cycle_analytics: {
start_date: startDate,
},
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 6536a8fd7fa..50bd394e90e 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -1,4 +1,5 @@
/* eslint-disable no-param-reassign */
+import { __ } from '../locale';
require('../lib/utils/text_utility');
const DEFAULT_EVENT_OBJECTS = require('./default_event_objects');
@@ -7,13 +8,13 @@ const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
const EMPTY_STAGE_TEXTS = {
- issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
- plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
- code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
- test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
- review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
- staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
- production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
+ issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'),
+ plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'),
+ code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'),
+ test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'),
+ review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'),
+ staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'),
+ production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'),
};
global.cycleAnalytics.CycleAnalyticsStore = {
@@ -38,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = {
});
newData.stages.forEach((item) => {
- const stageSlug = gl.text.dasherize(item.title.toLowerCase());
+ const stageSlug = gl.text.dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
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..b16ff2a0221 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();
@@ -250,6 +252,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
}
break;
case 'projects:pipelines:builds':
+ case 'projects:pipelines:failures':
case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
@@ -344,6 +347,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/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
index 8883ed9aa14..868d47e91b3 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/droplab/constants.js
@@ -3,11 +3,14 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
+// Matches `{{anything}}` and `{{ everything }}`.
+const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
export {
DATA_TRIGGER,
DATA_DROPDOWN,
SELECTED_CLASS,
ACTIVE_CLASS,
+ TEMPLATE_REGEX,
IGNORE_CLASS,
};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index 1fb4d63923c..de3927d683c 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -94,7 +94,7 @@ Object.assign(DropDown.prototype, {
},
renderChildren: function(data) {
- var html = utils.t(this.templateString, data);
+ var html = utils.template(this.templateString, data);
var template = document.createElement('div');
template.innerHTML = html;
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js
index c149a33a1e9..4da7344604e 100644
--- a/app/assets/javascripts/droplab/utils.js
+++ b/app/assets/javascripts/droplab/utils.js
@@ -1,19 +1,19 @@
/* eslint-disable */
-import { DATA_TRIGGER, DATA_DROPDOWN } from './constants';
+import { template as _template } from 'underscore';
+import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants';
const utils = {
toCamelCase(attr) {
return this.camelize(attr.split('-').slice(1).join(' '));
},
- t(s, d) {
- for (const p in d) {
- if (Object.prototype.hasOwnProperty.call(d, p)) {
- s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]);
- }
- }
- return s;
+ template(templateString, data) {
+ const template = _template(templateString, {
+ escape: TEMPLATE_REGEX,
+ });
+
+ return template(data);
},
camelize(str) {
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/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
index 9126422b335..15052dbd362 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
@@ -8,6 +8,11 @@ export default {
type: Array,
required: true,
},
+ isLocalStorageAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
@@ -47,7 +52,12 @@ export default {
template: `
<div>
- <ul v-if="hasItems">
+ <div
+ v-if="!isLocalStorageAvailable"
+ class="dropdown-info-note">
+ This feature requires local storage to be enabled
+ </div>
+ <ul v-else-if="hasItems">
<li
v-for="(item, index) in processedItems"
:key="index">
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 3e7a892756c..5e9434fd48f 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -62,7 +62,7 @@ class DropdownHint extends gl.FilteredSearchDropdown {
Object.assign({
icon: `fa-${icon}`,
hint,
- tag: `&lt;${tag}&gt;`,
+ tag: `<${tag}>`,
}, type && { type }),
);
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 36af0674ac6..9fea563370f 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,5 +1,3 @@
-/* global Flash */
-
import FilteredSearchContainer from './container';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
@@ -15,7 +13,9 @@ class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
- this.recentSearchesStore = new RecentSearchesStore();
+ this.recentSearchesStore = new RecentSearchesStore({
+ isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ });
let recentSearchesKey = 'issue-recent-searches';
if (page === 'merge_requests') {
recentSearchesKey = 'merge-request-recent-searches';
@@ -24,9 +24,10 @@ class FilteredSearchManager {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
- .catch(() => {
+ .catch((error) => {
+ if (error.name === 'RecentSearchesServiceError') return undefined;
// eslint-disable-next-line no-new
- new Flash('An error occured while parsing recent searches');
+ new window.Flash('An error occured while parsing recent searches');
// Gracefully fail to empty array
return [];
})
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 453ecccc6fc..f3003b86493 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,3 +1,5 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+import '~/flash'; /* global Flash */
import FilteredSearchContainer from './container';
class FilteredSearchVisualTokens {
@@ -48,6 +50,40 @@ class FilteredSearchVisualTokens {
`;
}
+ static updateLabelTokenColor(tokenValueContainer, tokenValue) {
+ const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
+ const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
+ const labelsEndpoint = `${baseEndpoint}/labels.json`;
+
+ return AjaxCache.retrieve(labelsEndpoint)
+ .then((labels) => {
+ const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
+
+ if (!matchingLabel) {
+ return;
+ }
+
+ const tokenValueStyle = tokenValueContainer.style;
+ tokenValueStyle.backgroundColor = matchingLabel.color;
+ tokenValueStyle.color = matchingLabel.text_color;
+
+ if (matchingLabel.text_color === '#FFFFFF') {
+ const removeToken = tokenValueContainer.querySelector('.remove-token');
+ removeToken.classList.add('inverted');
+ }
+ })
+ .catch(() => new Flash('An error occurred while fetching label colors.'));
+ }
+
+ static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ const tokenValueContainer = parentElement.querySelector('.value-container');
+ tokenValueContainer.querySelector('.value').innerText = tokenValue;
+
+ if (tokenName.toLowerCase() === 'label') {
+ FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ }
+ }
+
static addVisualTokenElement(name, value, isSearchTerm) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
@@ -55,7 +91,7 @@ class FilteredSearchVisualTokens {
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
- li.querySelector('.value').innerText = value;
+ FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
}
@@ -74,7 +110,7 @@ class FilteredSearchVisualTokens {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
lastVisualToken.querySelector('.name').innerText = name;
- lastVisualToken.querySelector('.value').innerText = value;
+ FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
}
}
@@ -183,6 +219,9 @@ class FilteredSearchVisualTokens {
static moveInputToTheRight() {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
+
+ if (!input) return;
+
const inputLi = input.parentElement;
const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
index 4e38409e12a..b2e6f63aacf 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_root.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -29,12 +29,15 @@ class RecentSearchesRoot {
}
render() {
+ const state = this.store.state;
this.vm = new Vue({
el: this.wrapperElement,
- data: this.store.state,
+ data() { return state; },
template: `
<recent-searches-dropdown-content
- :items="recentSearches" />
+ :items="recentSearches"
+ :is-local-storage-available="isLocalStorageAvailable"
+ />
`,
components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
index 3e402d5aed0..a056dea928d 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
@@ -1,9 +1,17 @@
+import RecentSearchesServiceError from './recent_searches_service_error';
+import AccessorUtilities from '../../lib/utils/accessor';
+
class RecentSearchesService {
constructor(localStorageKey = 'issuable-recent-searches') {
this.localStorageKey = localStorageKey;
}
fetch() {
+ if (!RecentSearchesService.isAvailable()) {
+ const error = new RecentSearchesServiceError();
+ return Promise.reject(error);
+ }
+
const input = window.localStorage.getItem(this.localStorageKey);
let searches = [];
@@ -19,8 +27,14 @@ class RecentSearchesService {
}
save(searches = []) {
+ if (!RecentSearchesService.isAvailable()) return;
+
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
}
+
+ static isAvailable() {
+ return AccessorUtilities.isLocalStorageAccessSafe();
+ }
}
export default RecentSearchesService;
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
new file mode 100644
index 00000000000..5917b223d63
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
@@ -0,0 +1,11 @@
+class RecentSearchesServiceError {
+ constructor(message) {
+ this.name = 'RecentSearchesServiceError';
+ this.message = message || 'Recent Searches Service is unavailable';
+ }
+}
+
+// Can't use `extends` for builtin prototypes and get true inheritance yet
+RecentSearchesServiceError.prototype = Error.prototype;
+
+export default RecentSearchesServiceError;
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_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index a03f1202a6d..0c9eb84f0eb 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -255,7 +255,8 @@ GitLabDropdown = (function() {
}
};
// Remote data
- })(this)
+ })(this),
+ instance: this,
});
}
}
@@ -269,6 +270,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote,
query: this.options.data,
keys: searchFields,
+ instance: this,
elements: (function(_this) {
return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
@@ -343,21 +345,26 @@ GitLabDropdown = (function() {
}
this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking;
- $el = $(this);
+ $el = $(e.currentTarget);
selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null;
- if (self.options.clicked) {
- self.options.clicked(selectedObj, $el, e, isMarking);
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
}
// Update label right after all modifications in dropdown has been done
- if (self.options.toggleLabel) {
- self.updateLabel(selectedObj, $el, self);
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
}
$el.trigger('blur');
- });
+ }.bind(this));
}
}
@@ -439,15 +446,34 @@ GitLabDropdown = (function() {
}
};
+ GitLabDropdown.prototype.filteredFullData = function() {
+ return this.fullData.filter(r => typeof r === 'object'
+ && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
+ && !Object.prototype.hasOwnProperty.call(r, 'header')
+ );
+ };
+
GitLabDropdown.prototype.opened = function(e) {
var contentHtml;
this.resetRows();
this.addArrowKeyEvent();
+ const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
+ const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
+ const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
+
// Makes indeterminate items effective
- if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
+ if (this.fullData && hasFilterBulkUpdate) {
this.parseData(this.fullData);
}
+
+ // Process the data to make sure rendered data
+ // matches the correct layout
+ if (this.fullData && hasMultiSelect && this.options.processData) {
+ const inputValue = this.filterInput.val();
+ this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
+ }
+
contentHtml = $('.dropdown-content', this.dropdown).html();
if (this.remote && contentHtml === "") {
this.remote.execute();
@@ -709,6 +735,11 @@ GitLabDropdown = (function() {
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
+
+ if (this.options.inputMeta) {
+ $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ }
+
return this.dropdown.before($input);
};
@@ -829,7 +860,14 @@ GitLabDropdown = (function() {
if (instance == null) {
instance = null;
}
- return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
+
+ let toggleText = this.options.toggleLabel(selected, el, instance);
+ if (this.options.updateLabel) {
+ // Option to override the dropdown label text
+ toggleText = this.options.updateLabel;
+ }
+
+ return $(this.el).find(".dropdown-toggle-text").text(toggleText);
};
GitLabDropdown.prototype.clearField = function(field, isInput) {
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/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js
deleted file mode 100644
index e927cc0077c..00000000000
--- a/app/assets/javascripts/issuable/issuable_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-require('./time_tracking/time_tracking_bundle');
diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js
deleted file mode 100644
index aec13e78f42..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Vue from 'vue';
-import stopwatchSvg from 'icons/_icon_stopwatch.svg';
-
-require('../../../lib/utils/pretty_time');
-
-(() => {
- Vue.component('time-tracking-collapsed-state', {
- name: 'time-tracking-collapsed-state',
- props: [
- 'showComparisonState',
- 'showSpentOnlyState',
- 'showEstimateOnlyState',
- 'showNoTimeTrackingState',
- 'timeSpentHumanReadable',
- 'timeEstimateHumanReadable',
- ],
- methods: {
- abbreviateTime(timeStr) {
- return gl.utils.prettyTime.abbreviateTime(timeStr);
- },
- },
- template: `
- <div class='sidebar-collapsed-icon'>
- ${stopwatchSvg}
- <div class='time-tracking-collapsed-summary'>
- <div class='compare' v-if='showComparisonState'>
- <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
- </div>
- <div class='estimate-only' v-if='showEstimateOnlyState'>
- <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
- </div>
- <div class='spend-only' v-if='showSpentOnlyState'>
- <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span>
- </div>
- <div class='no-tracking' v-if='showNoTimeTrackingState'>
- <span class='no-value'>None</span>
- </div>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js
deleted file mode 100644
index c55e263f6f4..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import Vue from 'vue';
-
-require('../../../lib/utils/pretty_time');
-
-(() => {
- const prettyTime = gl.utils.prettyTime;
-
- Vue.component('time-tracking-comparison-pane', {
- name: 'time-tracking-comparison-pane',
- props: [
- 'timeSpent',
- 'timeEstimate',
- 'timeSpentHumanReadable',
- 'timeEstimateHumanReadable',
- ],
- computed: {
- parsedRemaining() {
- const diffSeconds = this.timeEstimate - this.timeSpent;
- return prettyTime.parseSeconds(diffSeconds);
- },
- timeRemainingHumanReadable() {
- return prettyTime.stringifyTime(this.parsedRemaining);
- },
- timeRemainingTooltip() {
- const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
- return `${prefix} ${this.timeRemainingHumanReadable}`;
- },
- /* Diff values for comparison meter */
- timeRemainingMinutes() {
- return this.timeEstimate - this.timeSpent;
- },
- timeRemainingPercent() {
- return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
- },
- timeRemainingStatusClass() {
- return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
- },
- /* Parsed time values */
- parsedEstimate() {
- return prettyTime.parseSeconds(this.timeEstimate);
- },
- parsedSpent() {
- return prettyTime.parseSeconds(this.timeSpent);
- },
- },
- template: `
- <div class='time-tracking-comparison-pane'>
- <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
- :aria-valuenow='timeRemainingTooltip'
- :title='timeRemainingTooltip'
- :data-original-title='timeRemainingTooltip'
- :class='timeRemainingStatusClass'>
- <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
- <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div>
- </div>
- <div class='compare-display-container'>
- <div class='compare-display pull-left'>
- <span class='compare-label'>Spent</span>
- <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span>
- </div>
- <div class='compare-display estimated pull-right'>
- <span class='compare-label'>Est</span>
- <span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
- </div>
- </div>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js
deleted file mode 100644
index a7fbd704c40..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-estimate-only-pane', {
- name: 'time-tracking-estimate-only-pane',
- props: ['timeEstimateHumanReadable'],
- template: `
- <div class='time-tracking-estimate-only-pane'>
- <span class='bold'>Estimated:</span>
- {{ timeEstimateHumanReadable }}
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js
deleted file mode 100644
index 344b29ebea4..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-help-state', {
- name: 'time-tracking-help-state',
- props: ['docsUrl'],
- template: `
- <div class='time-tracking-help-state'>
- <div class='time-tracking-info'>
- <h4>Track time with slash commands</h4>
- <p>Slash commands can be used in the issues description and comment boxes.</p>
- <p>
- <code>/estimate</code>
- will update the estimated time with the latest command.
- </p>
- <p>
- <code>/spend</code>
- will update the sum of the time spent.
- </p>
- <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
deleted file mode 100644
index b081adf5e64..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-no-tracking-pane', {
- name: 'time-tracking-no-tracking-pane',
- template: `
- <div class='time-tracking-no-tracking-pane'>
- <span class='no-value'>No estimate or time spent</span>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js
deleted file mode 100644
index edb9169112f..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-spent-only-pane', {
- name: 'time-tracking-spent-only-pane',
- props: ['timeSpentHumanReadable'],
- template: `
- <div class='time-tracking-spend-only-pane'>
- <span class='bold'>Spent:</span>
- {{ timeSpentHumanReadable }}
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js
deleted file mode 100644
index 0213522f551..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import Vue from 'vue';
-
-require('./help_state');
-require('./collapsed_state');
-require('./spent_only_pane');
-require('./no_tracking_pane');
-require('./estimate_only_pane');
-require('./comparison_pane');
-
-(() => {
- Vue.component('issuable-time-tracker', {
- name: 'issuable-time-tracker',
- props: [
- 'time_estimate',
- 'time_spent',
- 'human_time_estimate',
- 'human_time_spent',
- 'docsUrl',
- ],
- data() {
- return {
- showHelp: false,
- };
- },
- computed: {
- timeSpent() {
- return this.time_spent;
- },
- timeEstimate() {
- return this.time_estimate;
- },
- timeEstimateHumanReadable() {
- return this.human_time_estimate;
- },
- timeSpentHumanReadable() {
- return this.human_time_spent;
- },
- hasTimeSpent() {
- return !!this.timeSpent;
- },
- hasTimeEstimate() {
- return !!this.timeEstimate;
- },
- showComparisonState() {
- return this.hasTimeEstimate && this.hasTimeSpent;
- },
- showEstimateOnlyState() {
- return this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showSpentOnlyState() {
- return this.hasTimeSpent && !this.hasTimeEstimate;
- },
- showNoTimeTrackingState() {
- return !this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showHelpState() {
- return !!this.showHelp;
- },
- },
- methods: {
- toggleHelpState(show) {
- this.showHelp = show;
- },
- },
- template: `
- <div class='time_tracker time-tracking-component-wrap' v-cloak>
- <time-tracking-collapsed-state
- :show-comparison-state='showComparisonState'
- :show-help-state='showHelpState'
- :show-spent-only-state='showSpentOnlyState'
- :show-estimate-only-state='showEstimateOnlyState'
- :time-spent-human-readable='timeSpentHumanReadable'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-collapsed-state>
- <div class='title hide-collapsed'>
- Time tracking
- <div class='help-button pull-right'
- v-if='!showHelpState'
- @click='toggleHelpState(true)'>
- <i class='fa fa-question-circle' aria-hidden='true'></i>
- </div>
- <div class='close-help-button pull-right'
- v-if='showHelpState'
- @click='toggleHelpState(false)'>
- <i class='fa fa-close' aria-hidden='true'></i>
- </div>
- </div>
- <div class='time-tracking-content hide-collapsed'>
- <time-tracking-estimate-only-pane
- v-if='showEstimateOnlyState'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-estimate-only-pane>
- <time-tracking-spent-only-pane
- v-if='showSpentOnlyState'
- :time-spent-human-readable='timeSpentHumanReadable'>
- </time-tracking-spent-only-pane>
- <time-tracking-no-tracking-pane
- v-if='showNoTimeTrackingState'>
- </time-tracking-no-tracking-pane>
- <time-tracking-comparison-pane
- v-if='showComparisonState'
- :time-estimate='timeEstimate'
- :time-spent='timeSpent'
- :time-spent-human-readable='timeSpentHumanReadable'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-comparison-pane>
- <transition name='help-state-toggle'>
- <time-tracking-help-state
- v-if='showHelpState'
- :docs-url='docsUrl'>
- </time-tracking-help-state>
- </transition>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
deleted file mode 100644
index 1689a69e1ed..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('./components/time_tracker');
-require('../../smart_interval');
-require('../../subbable_resource');
-
-Vue.use(VueResource);
-
-(() => {
- /* This Vue instance represents what will become the parent instance for the
- * sidebar. It will be responsible for managing `issuable` state and propagating
- * changes to sidebar components. We will want to create a separate service to
- * interface with the server at that point.
- */
-
- class IssuableTimeTracking {
- constructor(issuableJSON) {
- const parsedIssuable = JSON.parse(issuableJSON);
- return this.initComponent(parsedIssuable);
- }
-
- initComponent(parsedIssuable) {
- this.parentInstance = new Vue({
- el: '#issuable-time-tracker',
- data: {
- issuable: parsedIssuable,
- },
- methods: {
- fetchIssuable() {
- return gl.IssuableResource.get.call(gl.IssuableResource, {
- type: 'GET',
- url: gl.IssuableResource.endpoint,
- });
- },
- updateState(data) {
- this.issuable = data;
- },
- subscribeToUpdates() {
- gl.IssuableResource.subscribe(data => this.updateState(data));
- },
- listenForSlashCommands() {
- $(document).on('ajax:success', '.gfm-form', (e, data) => {
- const subscribedCommands = ['spend_time', 'time_estimate'];
- const changedCommands = data.commands_changes
- ? Object.keys(data.commands_changes)
- : [];
- if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
- this.fetchIssuable();
- }
- });
- },
- },
- created() {
- this.fetchIssuable();
- },
- mounted() {
- this.subscribeToUpdates();
- this.listenForSlashCommands();
- },
- });
- }
- }
-
- gl.IssuableTimeTracking = IssuableTimeTracking;
-})(window.gl || (window.gl = {}));
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/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js
new file mode 100644
index 00000000000..0740a9f559c
--- /dev/null
+++ b/app/assets/javascripts/issue_show/actions/tasks.js
@@ -0,0 +1,27 @@
+export default (newStateData, tasks) => {
+ const $tasks = $('#task_status');
+ const $tasksShort = $('#task_status_short');
+ const $issueableHeader = $('.issuable-header');
+ const tasksStates = { newState: null, currentState: null };
+
+ if ($tasks.length === 0) {
+ if (!(newStateData.task_status.indexOf('0 of 0') === 0)) {
+ $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`);
+ } else {
+ $issueableHeader.append('<span id="task_status"></span>');
+ }
+ } else {
+ tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0;
+ tasksStates.currentState = tasks.indexOf('0 of 0') === 0;
+ }
+
+ if ($tasks.length !== 0 && !tasksStates.newState) {
+ $tasks.text(newStateData.task_status);
+ $tasksShort.text(newStateData.task_status);
+ } else if (tasksStates.currentState) {
+ $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`);
+ } else if (tasksStates.newState) {
+ $tasks.remove();
+ $tasksShort.remove();
+ }
+};
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 4d491e70d83..eb20a597bb5 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,16 +1,16 @@
import Vue from 'vue';
-import IssueTitle from './issue_title.vue';
+import IssueTitle from './issue_title_description.vue';
import '../vue_shared/vue_resource_interceptor';
(() => {
const issueTitleData = document.querySelector('.issue-title-data').dataset;
- const { initialTitle, endpoint } = issueTitleData;
+ const { canUpdateTasksClass, endpoint } = issueTitleData;
const vm = new Vue({
el: '.issue-title-entrypoint',
render: createElement => createElement(IssueTitle, {
props: {
- initialTitle,
+ canUpdateTasksClass,
endpoint,
},
}),
diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue
deleted file mode 100644
index 00b0e56030a..00000000000
--- a/app/assets/javascripts/issue_show/issue_title.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-import Visibility from 'visibilityjs';
-import Poll from './../lib/utils/poll';
-import Service from './services/index';
-
-export default {
- props: {
- initialTitle: { required: true, type: String },
- endpoint: { required: true, type: String },
- },
- data() {
- const resource = new Service(this.$http, this.endpoint);
-
- const poll = new Poll({
- resource,
- method: 'getTitle',
- successCallback: (res) => {
- this.renderResponse(res);
- },
- errorCallback: (err) => {
- if (process.env.NODE_ENV !== 'production') {
- // eslint-disable-next-line no-console
- console.error('ISSUE SHOW TITLE REALTIME ERROR', err);
- } else {
- throw new Error(err);
- }
- },
- });
-
- return {
- poll,
- timeoutId: null,
- title: this.initialTitle,
- };
- },
- methods: {
- renderResponse(res) {
- const body = JSON.parse(res.body);
- this.triggerAnimation(body);
- },
- triggerAnimation(body) {
- const { title } = body;
-
- /**
- * since opacity is changed, even if there is no diff for Vue to update
- * we must check the title even on a 304 to ensure no visual change
- */
- if (this.title === title) return;
-
- this.$el.style.opacity = 0;
-
- this.timeoutId = setTimeout(() => {
- this.title = title;
-
- this.$el.style.transition = 'opacity 0.2s ease';
- this.$el.style.opacity = 1;
-
- clearTimeout(this.timeoutId);
- }, 100);
- },
- },
- created() {
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
- },
-};
-</script>
-
-<template>
- <h2 class="title" v-html="title"></h2>
-</template>
diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue
new file mode 100644
index 00000000000..dc3ba2550c5
--- /dev/null
+++ b/app/assets/javascripts/issue_show/issue_title_description.vue
@@ -0,0 +1,180 @@
+<script>
+import Visibility from 'visibilityjs';
+import Poll from './../lib/utils/poll';
+import Service from './services/index';
+import tasks from './actions/tasks';
+
+export default {
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ canUpdateTasksClass: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ const resource = new Service(this.$http, this.endpoint);
+
+ const poll = new Poll({
+ resource,
+ method: 'getTitle',
+ successCallback: (res) => {
+ this.renderResponse(res);
+ },
+ errorCallback: (err) => {
+ throw new Error(err);
+ },
+ });
+
+ return {
+ poll,
+ apiData: {},
+ tasks: '0 of 0',
+ title: null,
+ titleText: '',
+ titleFlag: {
+ pre: true,
+ pulse: false,
+ },
+ description: null,
+ descriptionText: '',
+ descriptionChange: false,
+ descriptionFlag: {
+ pre: true,
+ pulse: false,
+ },
+ timeAgoEl: $('.issue_edited_ago'),
+ titleEl: document.querySelector('title'),
+ };
+ },
+ methods: {
+ updateFlag(key, toggle) {
+ this[key].pre = toggle;
+ this[key].pulse = !toggle;
+ },
+ renderResponse(res) {
+ this.apiData = res.json();
+ this.triggerAnimation();
+ },
+ updateTaskHTML() {
+ tasks(this.apiData, this.tasks);
+ },
+ elementsToVisualize(noTitleChange, noDescriptionChange) {
+ if (!noTitleChange) {
+ this.titleText = this.apiData.title_text;
+ this.updateFlag('titleFlag', true);
+ }
+
+ if (!noDescriptionChange) {
+ // only change to true when we need to bind TaskLists the html of description
+ this.descriptionChange = true;
+ this.updateTaskHTML();
+ this.tasks = this.apiData.task_status;
+ this.updateFlag('descriptionFlag', true);
+ }
+ },
+ setTabTitle() {
+ const currentTabTitleScope = this.titleEl.innerText.split('·');
+ currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `;
+ this.titleEl.innerText = currentTabTitleScope.join('·');
+ },
+ animate(title, description) {
+ this.title = title;
+ this.description = description;
+ this.setTabTitle();
+
+ this.$nextTick(() => {
+ this.updateFlag('titleFlag', false);
+ this.updateFlag('descriptionFlag', false);
+ });
+ },
+ triggerAnimation() {
+ // always reset to false before checking the change
+ this.descriptionChange = false;
+
+ const { title, description } = this.apiData;
+ this.descriptionText = this.apiData.description_text;
+
+ const noTitleChange = this.title === title;
+ const noDescriptionChange = this.description === description;
+
+ /**
+ * since opacity is changed, even if there is no diff for Vue to update
+ * we must check the title/description even on a 304 to ensure no visual change
+ */
+ if (noTitleChange && noDescriptionChange) return;
+
+ this.elementsToVisualize(noTitleChange, noDescriptionChange);
+ this.animate(title, description);
+ },
+ updateEditedTimeAgo() {
+ const toolTipTime = gl.utils.formatDate(this.apiData.updated_at);
+ this.timeAgoEl.attr('datetime', this.apiData.updated_at);
+ this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle');
+ },
+ },
+ created() {
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ },
+ updated() {
+ // if new html is injected (description changed) - bind TaskList and call renderGFM
+ if (this.descriptionChange) {
+ this.updateEditedTimeAgo();
+
+ $(this.$refs['issue-content-container-gfm-entry']).renderGFM();
+
+ const tl = new gl.TaskList({
+ dataType: 'issue',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ });
+
+ return tl && null;
+ }
+
+ return null;
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h2
+ class="title"
+ :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }"
+ ref="issue-title"
+ v-html="title"
+ >
+ </h2>
+ <div
+ class="description is-task-list-enabled"
+ :class="canUpdateTasksClass"
+ v-if="description"
+ >
+ <div
+ class="wiki"
+ :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }"
+ v-html="description"
+ ref="issue-content-container-gfm-entry"
+ >
+ </div>
+ <textarea
+ class="hidden js-task-list-field"
+ v-if="descriptionText"
+ >{{descriptionText}}</textarea>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index b2cfd3ef2a3..56cb536dcde 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
index e0ebd36a65c..fee3429e2b8 100644
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ b/app/assets/javascripts/issues_bulk_assignment.js
@@ -88,7 +88,10 @@
const formData = {
update: {
state_event: this.form.find('input[name="update[state_event]"]').val(),
+ // For Merge Requests
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
+ // For Issues
+ assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 9a60f5464df..ac5ce84e31b 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -330,7 +330,10 @@
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(label, $el, e, isMarking) {
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const label = options.selectedObj;
+
var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => {
$loading.fadeOut();
@@ -352,7 +355,7 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
_this.enableBulkLabelDropdown();
- _this.setDropdownData($dropdown, isMarking, this.id(label));
+ _this.setDropdownData($dropdown, isMarking, label.id);
return;
}
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
new file mode 100644
index 00000000000..1d18992af63
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -0,0 +1,47 @@
+function isPropertyAccessSafe(base, property) {
+ let safe;
+
+ try {
+ safe = !!base[property];
+ } catch (error) {
+ safe = false;
+ }
+
+ return safe;
+}
+
+function isFunctionCallSafe(base, functionName, ...args) {
+ let safe = true;
+
+ try {
+ base[functionName](...args);
+ } catch (error) {
+ safe = false;
+ }
+
+ return safe;
+}
+
+function isLocalStorageAccessSafe() {
+ let safe;
+
+ const TEST_KEY = 'isLocalStorageAccessSafe';
+ const TEST_VALUE = 'true';
+
+ safe = isPropertyAccessSafe(window, 'localStorage');
+ if (!safe) return safe;
+
+ safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE);
+
+ if (safe) window.localStorage.removeItem(TEST_KEY);
+
+ return safe;
+}
+
+const AccessorUtilities = {
+ isPropertyAccessSafe,
+ isFunctionCallSafe,
+ isLocalStorageAccessSafe,
+};
+
+export default AccessorUtilities;
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
new file mode 100644
index 00000000000..d99eefb5089
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -0,0 +1,32 @@
+const AjaxCache = {
+ internalStorage: { },
+ get(endpoint) {
+ return this.internalStorage[endpoint];
+ },
+ hasData(endpoint) {
+ return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint);
+ },
+ purge(endpoint) {
+ delete this.internalStorage[endpoint];
+ },
+ retrieve(endpoint) {
+ if (AjaxCache.hasData(endpoint)) {
+ return Promise.resolve(AjaxCache.get(endpoint));
+ }
+
+ return new Promise((resolve, reject) => {
+ $.ajax(endpoint) // eslint-disable-line promise/catch-or-return
+ .then(data => resolve(data),
+ (jqXHR, textStatus, errorThrown) => {
+ const error = new Error(`${endpoint}: ${errorThrown}`);
+ error.textStatus = textStatus;
+ reject(error);
+ },
+ );
+ })
+ .then((data) => { this.internalStorage[endpoint] = data; })
+ .then(() => AjaxCache.get(endpoint));
+ },
+};
+
+export default AjaxCache;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 8058672eaa9..2f682fbd2fb 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -35,6 +35,14 @@
});
};
+ w.gl.utils.ajaxPost = function(url, data) {
+ return $.ajax({
+ type: 'POST',
+ url: url,
+ data: data,
+ });
+ };
+
w.gl.utils.extractLast = function(term) {
return this.split(term).pop();
};
diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js
new file mode 100644
index 00000000000..e96090da80e
--- /dev/null
+++ b/app/assets/javascripts/locale/de/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js
new file mode 100644
index 00000000000..ade9b667b3c
--- /dev/null
+++ b/app/assets/javascripts/locale/en/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js
new file mode 100644
index 00000000000..3dafa21f235
--- /dev/null
+++ b/app/assets/javascripts/locale/es/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d día","Últimos %d días"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["día","días"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
new file mode 100644
index 00000000000..7ba676d6d20
--- /dev/null
+++ b/app/assets/javascripts/locale/index.js
@@ -0,0 +1,70 @@
+import Jed from 'jed';
+
+/**
+ This is required to require all the translation folders in the current directory
+ this saves us having to do this manually & keep up to date with new languages
+**/
+function requireAll(requireContext) { return requireContext.keys().map(requireContext); }
+
+const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/));
+const locales = allLocales.reduce((d, obj) => {
+ const data = d;
+ const localeKey = Object.keys(obj)[0];
+
+ data[localeKey] = obj[localeKey];
+
+ return data;
+}, {});
+
+let lang = document.querySelector('html').getAttribute('lang') || 'en';
+lang = lang.replace(/-/g, '_');
+
+const locale = new Jed(locales[lang]);
+
+/**
+ Translates `text`
+
+ @param text The text to be translated
+ @returns {String} The translated text
+**/
+const gettext = locale.gettext.bind(locale);
+
+/**
+ Translate the text with a number
+ if the number is more than 1 it will use the `pluralText` translation.
+ This method allows for contexts, see below re. contexts
+
+ @param text Singular text to translate (eg. '%d day')
+ @param pluralText Plural text to translate (eg. '%d days')
+ @param count Number to decide which translation to use (eg. 2)
+ @returns {String} Translated text with the number replaced (eg. '2 days')
+**/
+const ngettext = (text, pluralText, count) => {
+ const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|');
+
+ return translated[translated.length - 1];
+};
+
+/**
+ Translate context based text
+ Either pass in the context translation like `Context|Text to translate`
+ or allow for dynamic text by doing passing in the context first & then the text to translate
+
+ @param keyOrContext Can be either the key to translate including the context
+ (eg. 'Context|Text') or just the context for the translation
+ (eg. 'Context')
+ @param key Is the dynamic variable you want to be translated
+ @returns {String} Translated context based text
+**/
+const pgettext = (keyOrContext, key) => {
+ const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext;
+ const translated = gettext(normalizedKey).split('|');
+
+ return translated[translated.length - 1];
+};
+
+export { lang };
+export { gettext as __ };
+export { ngettext as n__ };
+export { pgettext as s__ };
+export default locale;
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index be3c2c9fbb1..1b0d5fc92e3 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -158,7 +158,6 @@ import './single_file_diff';
import './smart_interval';
import './snippets_list';
import './star';
-import './subbable_resource';
import './subscription';
import './subscription_select';
import './syntax_highlight';
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index e3f367a11eb..8291b8c4a70 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -31,8 +31,8 @@
toggleLabel(selected, $el) {
return $el.text();
},
- clicked: (selected, $link) => {
- this.formSubmit(null, $link);
+ clicked: (options) => {
+ this.formSubmit(null, options.$el);
},
});
});
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
index 42ecf0d6cb2..6f6ae9bde92 100644
--- a/app/assets/javascripts/merge_request_widget.js
+++ b/app/assets/javascripts/merge_request_widget.js
@@ -291,7 +291,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
MergeRequestWidget.prototype.updateCommitUrls = function(id) {
const commitsUrl = this.opts.commits_path;
- $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/'));
+ $('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/'));
};
MergeRequestWidget.prototype.initMiniPipelineGraph = function() {
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/milestone_select.js b/app/assets/javascripts/milestone_select.js
index bebd0aa357e..11e68c0a3be 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -121,7 +121,10 @@
return $value.css('display', '');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(selected, $el, e) {
+ clicked: function(options) {
+ const { $el, e } = options;
+ let selected = options.selectedObj;
+
var data, isIssueIndex, isMRIndex, page, boardsStore;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
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/namespace_select.js b/app/assets/javascripts/namespace_select.js
index b98e6121967..36bc1257cef 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -58,7 +58,8 @@
});
}
- NamespaceSelect.prototype.onSelectItem = function(item, el, e) {
+ NamespaceSelect.prototype.onSelectItem = function(options) {
+ const { e } = options;
return e.preventDefault();
};
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 5828f460a23..67046d52a65 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -34,6 +34,7 @@
filterByText: true,
remote: false,
fieldName: $branchSelect.data('field-name'),
+ filterInput: 'input[type="search"]',
selectable: true,
isSelectable: function(branch, $el) {
return !$el.hasClass('is-active');
@@ -50,6 +51,21 @@
}
}
});
+
+ const $dropdownContainer = $branchSelect.closest('.dropdown');
+ const $fieldInput = $(`input[name="${$branchSelect.data('field-name')}"]`, $dropdownContainer);
+ const $filterInput = $('input[type="search"]', $dropdownContainer);
+
+ $filterInput.on('keyup', (e) => {
+ const keyCode = e.keyCode || e.which;
+ if (keyCode !== 13) return;
+
+ const text = $filterInput.val();
+ $fieldInput.val(text);
+ $('.dropdown-toggle-text', $branchSelect).text(text);
+
+ $dropdownContainer.removeClass('open');
+ });
};
NewBranchForm.prototype.setupRestrictions = function() {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 974fb0d83da..55391ebc089 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,17 +17,22 @@ 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); }; };
this.Notes = (function() {
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+ const REGEX_SLASH_COMMANDS = /\/\w+/g;
Notes.interval = null;
function Notes(notes_url, note_ids, last_fetched_at, view) {
this.updateTargetButtons = bind(this.updateTargetButtons, this);
- this.updateCloseButton = bind(this.updateCloseButton, this);
+ this.updateComment = bind(this.updateComment, this);
this.visibilityChange = bind(this.visibilityChange, this);
this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this);
this.addDiffNote = bind(this.addDiffNote, this);
@@ -42,13 +48,18 @@ require('./task_list');
this.refresh = bind(this.refresh, this);
this.keydownNoteText = bind(this.keydownNoteText, this);
this.toggleCommitList = bind(this.toggleCommitList, this);
+ this.postComment = bind(this.postComment, 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();
@@ -73,28 +84,19 @@ require('./task_list');
};
Notes.prototype.addBinding = function() {
- // add note to UI after creation
- $(document).on("ajax:success", ".js-main-target-form", this.addNote);
- $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
- // catch note ajax errors
- $(document).on("ajax:error", ".js-main-target-form", this.addNoteError);
- // change note in UI after update
- $(document).on("ajax:success", "form.edit-note", this.updateNote);
// Edit note link
$(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit
- $(document).on("click", ".js-comment-button", this.updateCloseButton);
+ $(document).on("click", ".js-comment-submit-button", this.postComment);
+ $(document).on("click", ".js-comment-save-button", this.updateComment);
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
// resolve a discussion
- $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
+ $(document).on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
$(document).on("click", ".js-note-delete", this.removeNote);
// delete note attachment
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
- // reset main target form after submit
- $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
- $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
// reset main target form when clicking discard
$(document).on("click", ".js-note-discard", this.resetMainTargetForm);
// update the file name when an attachment is selected
@@ -111,30 +113,33 @@ require('./task_list');
$(document).on("visibilitychange", this.visibilityChange);
// when issue status changes, we need to refresh data
$(document).on("issuable:change", this.refresh);
+ // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
+ $(document).on("ajax:success", ".js-main-target-form", this.addNote);
+ $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
+ $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
+ $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
// when a key is clicked on the notes
return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
};
Notes.prototype.cleanBinding = function() {
- $(document).off("ajax:success", ".js-main-target-form");
- $(document).off("ajax:success", ".js-discussion-note-form");
- $(document).off("ajax:success", "form.edit-note");
$(document).off("click", ".js-note-edit");
$(document).off("click", ".note-edit-cancel");
$(document).off("click", ".js-note-delete");
$(document).off("click", ".js-note-attachment-delete");
- $(document).off("ajax:complete", ".js-main-target-form");
- $(document).off("ajax:success", ".js-main-target-form");
$(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");
$(document).off("keydown", ".js-note-text");
$(document).off('click', '.js-comment-resolve-button');
$(document).off("click", '.system-note-commit-list-toggler');
+ $(document).off("ajax:success", ".js-main-target-form");
+ $(document).off("ajax:success", ".js-discussion-note-form");
+ $(document).off("ajax:complete", ".js-main-target-form");
};
Notes.initCommentTypeToggle = function (form) {
@@ -267,20 +272,16 @@ require('./task_list');
return this.initRefresh();
};
- Notes.prototype.handleCreateChanges = function(note) {
+ Notes.prototype.handleSlashCommands = function(noteEntity) {
var votesBlock;
- if (typeof note === '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 +293,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 +375,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 +409,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 +434,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);
@@ -511,24 +548,29 @@ require('./task_list');
Adds new note to list.
*/
- Notes.prototype.addNote = function(xhr, note, status) {
- this.handleCreateChanges(note);
+ Notes.prototype.addNote = function($form, note) {
return this.renderNote(note);
};
- Notes.prototype.addNoteError = function(xhr, note, status) {
- return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline);
+ Notes.prototype.addNoteError = ($form) => {
+ let formParentTimeline;
+ if ($form.hasClass('js-main-target-form')) {
+ formParentTimeline = $form.parents('.timeline');
+ } else if ($form.hasClass('js-discussion-note-form')) {
+ formParentTimeline = $form.closest('.discussion-notes').find('.notes');
+ }
+ return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline);
};
+ Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.');
+
/*
Called in response to the new note form being submitted
Adds new note to list.
*/
- Notes.prototype.addDiscussionNote = function(xhr, note, status) {
- var $form = $(xhr.target);
-
+ Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) {
if ($form.attr('data-resolve-all') != null) {
var projectPath = $form.data('project-path');
var discussionId = $form.data('discussion-id');
@@ -541,7 +583,9 @@ require('./task_list');
this.renderNote(note, $form);
// cleanup after successfully creating a diff/discussion note
- this.removeDiscussionNoteForm($form);
+ if (isNewDiffComment) {
+ this.removeDiscussionNoteForm($form);
+ }
};
/*
@@ -550,18 +594,19 @@ require('./task_list');
Updates the current note field.
*/
- Notes.prototype.updateNote = function(_xhr, note, _status) {
- var $html, $note_li;
+ Notes.prototype.updateNote = function(_xhr, noteEntity, _status) {
+ var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
- $html = $(note.html);
+ $noteEntityEl = $(noteEntity.html);
+ $noteEntityEl.addClass('fade-in-full');
this.revertNoteEditForm();
- gl.utils.localTimeAgo($('.js-timeago', $html));
- $html.renderGFM();
- $html.find('.js-task-list-container').taskList('enable');
+ gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl));
+ $noteEntityEl.renderGFM();
+ $noteEntityEl.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);
+ $note_li.replaceWith($noteEntityEl);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
@@ -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-comment-save-button').enable();
+ $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();
};
/*
@@ -925,14 +982,6 @@ require('./task_list');
return this.refresh();
};
- Notes.prototype.updateCloseButton = function(e) {
- var closebtn, form, textarea;
- textarea = $(e.target);
- form = textarea.parents('form');
- closebtn = form.find('.js-note-target-close');
- return closebtn.text(closebtn.data('original-text'));
- };
-
Notes.prototype.updateTargetButtons = function(e) {
var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
textarea = $(e.target);
@@ -1004,19 +1053,21 @@ require('./task_list');
$editForm.find('.referenced-users').hide();
};
- Notes.prototype.updateNotesCount = function(updateCount) {
- return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
+ 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.resolveDiscussion = function() {
- var $this = $(this);
- var discussionId = $this.attr('data-discussion-id');
-
- $this
- .closest('form')
- .attr('data-discussion-id', discussionId)
- .attr('data-resolve-all', 'true')
- .attr('data-project-path', $this.attr('data-project-path'));
+ Notes.prototype.updateNotesCount = function(updateCount) {
+ return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
};
Notes.prototype.toggleCommitList = function(e) {
@@ -1064,11 +1115,268 @@ 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();
+ $note.addClass('fade-in-full').renderGFM();
$notesList.append($note);
+ return $note;
+ };
+
+ Notes.animateUpdateNote = function(noteHtml, $note) {
+ const $updatedNote = $(noteHtml);
+
+ $updatedNote.addClass('fade-in').renderGFM();
+ $note.replaceWith($updatedNote);
+ return $updatedNote;
+ };
+
+ /**
+ * Get data from Form attributes to use for saving/submitting comment.
+ */
+ Notes.prototype.getFormData = function($form) {
+ return {
+ formData: $form.serialize(),
+ formContent: $form.find('.js-note-text').val(),
+ formAction: $form.attr('action'),
+ };
+ };
+
+ /**
+ * Identify if comment has any slash commands
+ */
+ Notes.prototype.hasSlashCommands = function(formContent) {
+ return REGEX_SLASH_COMMANDS.test(formContent);
+ };
+
+ /**
+ * Remove slash commands and leave comment with pure message
+ */
+ Notes.prototype.stripSlashCommands = function(formContent) {
+ return formContent.replace(REGEX_SLASH_COMMANDS, '').trim();
+ };
+
+ /**
+ * Create placeholder note DOM element populated with comment body
+ * that we will show while comment is being posted.
+ * Once comment is _actually_ posted on server, we will have final element
+ * in response that we will show in place of this temporary element.
+ */
+ Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
+ const discussionClass = isDiscussionNote ? 'discussion' : '';
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <a href="/${currentUsername}"><span class="dummy-avatar"></span></a>
+ </div>
+ <div class="timeline-content ${discussionClass}">
+ <div class="note-header">
+ <div class="note-header-info">
+ <a href="/${currentUsername}">
+ <span class="hidden-xs">${currentUserFullname}</span>
+ <span class="note-headline-light">@${currentUsername}</span>
+ </a>
+ <span class="note-headline-light">
+ <i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i>
+ </span>
+ </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>${formContent}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ };
+
+ /**
+ * This method does following tasks step-by-step whenever a new comment
+ * is submitted by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
+ * 3) Build temporary placeholder element (using `createPlaceholderNote`)
+ * 4) Show placeholder note on UI
+ * 5) Perform network request to submit the note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Remove placeholder element
+ * 2. Show submitted Note element
+ * 3. Perform post-submit errands
+ * a. Mark discussion as resolved if comment submission was for resolve.
+ * b. Reset comment form to original state.
+ * b) If request failed
+ * 1. Remove placeholder element
+ * 2. Show error Flash message about failure
+ */
+ Notes.prototype.postComment = function(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ let $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
+ const isMainForm = $form.hasClass('js-main-target-form');
+ const isDiscussionForm = $form.hasClass('js-discussion-note-form');
+ const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
+ const { formData, formContent, formAction } = this.getFormData($form);
+ const uniqueId = _.uniqueId('tempNote_');
+ let $notesContainer;
+ let tempFormContent;
+
+ // Get reference to notes container based on type of comment
+ if (isDiscussionForm) {
+ $notesContainer = $form.parent('.discussion-notes').find('.notes');
+ } else if (isMainForm) {
+ $notesContainer = $('ul.main-notes-list');
+ }
+
+ // If comment is to resolve discussion, disable submit buttons while
+ // comment posting is finished.
+ if (isDiscussionResolve) {
+ $submitBtn.disable();
+ $form.find('.js-comment-submit-button').disable();
+ }
+
+ tempFormContent = formContent;
+ if (this.hasSlashCommands(formContent)) {
+ tempFormContent = this.stripSlashCommands(formContent);
+ }
+
+ if (tempFormContent) {
+ // Show placeholder note
+ $notesContainer.append(this.createPlaceholderNote({
+ formContent: tempFormContent,
+ uniqueId,
+ isDiscussionNote,
+ currentUsername: gon.current_username,
+ currentUserFullname: gon.current_user_fullname,
+ }));
+ }
+
+ // Clear the form textarea
+ if ($notesContainer.length) {
+ if (isMainForm) {
+ this.resetMainTargetForm(e);
+ } else if (isDiscussionForm) {
+ this.removeDiscussionNoteForm($form);
+ }
+ }
+
+ /* eslint-disable promise/catch-or-return */
+ // Make request to submit comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! remove placeholder
+ $notesContainer.find(`#${uniqueId}`).remove();
+
+ // Check if this was discussion comment
+ if (isDiscussionForm) {
+ // Remove flash-container
+ $notesContainer.find('.flash-container').remove();
+
+ // If comment intends to resolve discussion, do the same.
+ if (isDiscussionResolve) {
+ $form
+ .attr('data-discussion-id', $submitBtn.data('discussion-id'))
+ .attr('data-resolve-all', 'true')
+ .attr('data-project-path', $submitBtn.data('project-path'));
+ }
+
+ // Show final note element on UI
+ this.addDiscussionNote($form, note, $notesContainer.length === 0);
+
+ // append flash-container to the Notes list
+ if ($notesContainer.length) {
+ $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
+ }
+ } else if (isMainForm) { // Check if this was main thread comment
+ // Show final note element on UI and perform form and action buttons cleanup
+ this.addNote($form, note);
+ this.reenableTargetFormSubmitButton(e);
+ }
+
+ if (note.commands_changes) {
+ this.handleSlashCommands(note);
+ }
+
+ $form.trigger('ajax:success', [note]);
+ }).fail(() => {
+ // Submission failed, remove placeholder note and show Flash error message
+ $notesContainer.find(`#${uniqueId}`).remove();
+
+ // Show form again on UI on failure
+ if (isDiscussionForm && $notesContainer.length) {
+ const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
+ $.proxy(this.replyToDiscussionNote, replyButton[0], { target: replyButton[0] }).call();
+ $form = $notesContainer.parent().find('form');
+ }
+
+ $form.find('.js-note-text').val(formContent);
+ this.reenableTargetFormSubmitButton(e);
+ this.addNoteError($form);
+ });
+
+ return $closeBtn.text($closeBtn.data('original-text'));
+ };
+
+ /**
+ * This method does following tasks step-by-step whenever an existing comment
+ * is updated by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Update note element with new content
+ * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Show submitted Note element
+ * b) If request failed
+ * 1. Revert Note element to original content
+ * 2. Show error Flash message about failure
+ */
+ Notes.prototype.updateComment = function(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ const $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const $editingNote = $form.parents('.note.is-editing');
+ const $noteBody = $editingNote.find('.js-task-list-container');
+ const $noteBodyText = $noteBody.find('.note-text');
+ const { formData, formContent, formAction } = this.getFormData($form);
+
+ // Cache original comment content
+ const cachedNoteBodyText = $noteBodyText.html();
+
+ // Show updated comment content temporarily
+ $noteBodyText.html(formContent);
+ $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
+ $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>');
+
+ /* eslint-disable promise/catch-or-return */
+ // Make request to update comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! render final note element
+ this.updateNote(null, note, null);
+ })
+ .fail(() => {
+ // Submission failed, revert back to original note
+ $noteBodyText.html(cachedNoteBodyText);
+ $editingNote.removeClass('being-posted fade-in');
+ $editingNote.find('.fa.fa-spinner').remove();
+
+ // Show Flash message about failure
+ this.updateNoteError();
+ });
+
+ return $closeBtn.text($closeBtn.data('original-text'));
};
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/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 255cd513490..b21f84b4545 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -40,6 +40,6 @@ export default class PipelinesService {
* @return {Promise}
*/
postAction(endpoint) {
- return Vue.http.post(endpoint, {}, { emulateJSON: true });
+ return Vue.http.post(`${endpoint}.json`);
}
}
diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js
index 07eea98e737..4a3df2fd465 100644
--- a/app/assets/javascripts/preview_markdown.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -2,8 +2,9 @@
// MarkdownPreview
//
-// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview,
-// and showing a warning when more than `x` users are referenced.
+// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
+// (including the explanation of slash commands), and showing a warning when
+// more than `x` users are referenced.
//
(function () {
var lastTextareaPreviewed;
@@ -17,32 +18,45 @@
// Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10;
+ MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) {
var mdText;
var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
if (mdText.trim().length === 0) {
- preview.text('Nothing to preview.');
+ preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
} else {
preview.addClass('md-preview-loading').text('Loading...');
- this.fetchMarkdownPreview(mdText, (function (response) {
- preview.removeClass('md-preview-loading').html(response.body);
+ this.fetchMarkdownPreview(mdText, url, (function (response) {
+ var body;
+ if (response.body.length > 0) {
+ body = response.body;
+ } else {
+ body = this.emptyMessage;
+ }
+
+ preview.removeClass('md-preview-loading').html(body);
preview.renderGFM();
this.renderReferencedUsers(response.references.users, $form);
+
+ if (response.references.commands) {
+ this.renderReferencedCommands(response.references.commands, $form);
+ }
}).bind(this));
}
};
- MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) {
- if (!window.preview_markdown_path) {
+ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
+ if (!url) {
return;
}
if (text === this.ajaxCache.text) {
@@ -51,7 +65,7 @@
}
$.ajax({
type: 'POST',
- url: window.preview_markdown_path,
+ url: url,
data: {
text: text
},
@@ -83,6 +97,22 @@
}
};
+ MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
+ $form.find('.referenced-commands').hide();
+ };
+
+ MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
+ var referencedCommands;
+ referencedCommands = $form.find('.referenced-commands');
+ if (commands.length > 0) {
+ referencedCommands.html(commands);
+ referencedCommands.show();
+ } else {
+ referencedCommands.html('');
+ referencedCommands.hide();
+ }
+ };
+
return MarkdownPreview;
}());
@@ -137,6 +167,8 @@
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
+
+ markdownPreview.hideReferencedCommands($form);
});
$(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index f944fcc5a58..738e710deb9 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) {
return $el.text().trim();
},
- clicked: function(selected, $el, e) {
+ clicked: function(options) {
+ const { e } = options;
e.preventDefault();
if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form');
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index e7fff57ff45..42993a252c3 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -19,7 +19,9 @@
return 'Select';
}
},
- clicked(item, $el, e) {
+ clicked(opts) {
+ const { e } = opts;
+
e.preventDefault();
onSelect();
}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
index 1d4bb8a13d6..bc6110fcd4e 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
@@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
+ clicked: (options) => {
+ const { $el, e } = options;
e.preventDefault();
this.onSelect();
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
index fff83f3af3b..d4c9a91a74a 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown {
}
return 'Select';
},
- clicked(item, $el, e) {
- e.preventDefault();
+ clicked(options) {
+ options.e.preventDefault();
onSelect();
},
});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 5ff4e443262..068e9698e1d 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -39,8 +39,8 @@ export default class ProtectedTagDropdown {
return _.escape(protectedTag.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
- e.preventDefault();
+ clicked: (options) => {
+ options.e.preventDefault();
this.onSelect();
},
});
diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js
new file mode 100644
index 00000000000..5325e495815
--- /dev/null
+++ b/app/assets/javascripts/raven/index.js
@@ -0,0 +1,16 @@
+import RavenConfig from './raven_config';
+
+const index = function index() {
+ RavenConfig.init({
+ sentryDsn: gon.sentry_dsn,
+ currentUserId: gon.current_user_id,
+ whitelistUrls: [gon.gitlab_url],
+ isProduction: process.env.NODE_ENV,
+ });
+
+ return RavenConfig;
+};
+
+index();
+
+export default index;
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js
new file mode 100644
index 00000000000..c7fe1cacf49
--- /dev/null
+++ b/app/assets/javascripts/raven/raven_config.js
@@ -0,0 +1,100 @@
+import Raven from 'raven-js';
+
+const IGNORE_ERRORS = [
+ // Random plugins/extensions
+ 'top.GLOBALS',
+ // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
+ 'originalCreateNotification',
+ 'canvas.contentDocument',
+ 'MyApp_RemoveAllHighlights',
+ 'http://tt.epicplay.com',
+ 'Can\'t find variable: ZiteReader',
+ 'jigsaw is not defined',
+ 'ComboSearch is not defined',
+ 'http://loading.retry.widdit.com/',
+ 'atomicFindClose',
+ // Facebook borked
+ 'fb_xd_fragment',
+ // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
+ // reduce this. (thanks @acdha)
+ // See http://stackoverflow.com/questions/4113268
+ 'bmi_SafeAddOnload',
+ 'EBCallBackMessageReceived',
+ // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
+ 'conduitPage',
+];
+
+const IGNORE_URLS = [
+ // Facebook flakiness
+ /graph\.facebook\.com/i,
+ // Facebook blocked
+ /connect\.facebook\.net\/en_US\/all\.js/i,
+ // Woopra flakiness
+ /eatdifferent\.com\.woopra-ns\.com/i,
+ /static\.woopra\.com\/js\/woopra\.js/i,
+ // Chrome extensions
+ /extensions\//i,
+ /^chrome:\/\//i,
+ // Other plugins
+ /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
+ /webappstoolbarba\.texthelp\.com\//i,
+ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
+];
+
+const SAMPLE_RATE = 95;
+
+const RavenConfig = {
+ IGNORE_ERRORS,
+ IGNORE_URLS,
+ SAMPLE_RATE,
+ init(options = {}) {
+ this.options = options;
+
+ this.configure();
+ this.bindRavenErrors();
+ if (this.options.currentUserId) this.setUser();
+ },
+
+ configure() {
+ Raven.config(this.options.sentryDsn, {
+ whitelistUrls: this.options.whitelistUrls,
+ environment: this.options.isProduction ? 'production' : 'development',
+ ignoreErrors: this.IGNORE_ERRORS,
+ ignoreUrls: this.IGNORE_URLS,
+ shouldSendCallback: this.shouldSendSample.bind(this),
+ }).install();
+ },
+
+ setUser() {
+ Raven.setUserContext({
+ id: this.options.currentUserId,
+ });
+ },
+
+ bindRavenErrors() {
+ window.$(document).on('ajaxError.raven', this.handleRavenErrors);
+ },
+
+ handleRavenErrors(event, req, config, err) {
+ const error = err || req.statusText;
+ const responseText = req.responseText || 'Unknown response text';
+
+ Raven.captureMessage(error, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: responseText,
+ error,
+ event,
+ },
+ });
+ },
+
+ shouldSendSample() {
+ return Math.random() * 100 <= this.SAMPLE_RATE;
+ },
+};
+
+export default RavenConfig;
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
new file mode 100644
index 00000000000..a9ad3708514
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
@@ -0,0 +1,41 @@
+export default {
+ name: 'AssigneeTitle',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfAssignees: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ assigneeTitle() {
+ const assignees = this.numberOfAssignees;
+ return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ },
+ },
+ template: `
+ <div class="title hide-collapsed">
+ {{assigneeTitle}}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ />
+ <a
+ v-if="editable"
+ class="edit-link pull-right"
+ href="#"
+ >
+ Edit
+ </a>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
new file mode 100644
index 00000000000..7e5feac622c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js
@@ -0,0 +1,224 @@
+export default {
+ name: 'Assignees',
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: true,
+ };
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasAssignees() {
+ return this.users.length > 0;
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ renderShowMoreSection() {
+ return this.users.length > this.defaultRenderCount;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - this.defaultRenderCount;
+ },
+ isHiddenAssignees() {
+ return this.numberOfHiddenAssignees > 0;
+ },
+ hiddenAssigneesLabel() {
+ return `+ ${this.numberOfHiddenAssignees} more`;
+ },
+ collapsedTooltipTitle() {
+ const maxRender = Math.min(this.defaultRenderCount, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (this.users.length > maxRender) {
+ names.push(`+ ${this.users.length - maxRender} more`);
+ }
+
+ return names.join(', ');
+ },
+ sidebarAvatarCounter() {
+ let counter = `+${this.users.length - 1}`;
+
+ if (this.users.length > this.defaultMaxCounter) {
+ counter = `${this.defaultMaxCounter}+`;
+ }
+
+ return counter;
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ renderAssignee(index) {
+ return !this.showLess || (index < this.defaultRenderCount && this.showLess);
+ },
+ avatarUrl(user) {
+ return user.avatar || user.avatar_url;
+ },
+ assigneeUrl(user) {
+ return `${this.rootPath}${user.username}`;
+ },
+ assigneeAlt(user) {
+ return `${user.name}'s avatar`;
+ },
+ assigneeUsername(user) {
+ return `@${user.username}`;
+ },
+ shouldRenderCollapsedAssignee(index) {
+ const firstTwo = this.users.length <= 2 && index <= 2;
+
+ return index === 0 || firstTwo;
+ },
+ },
+ template: `
+ <div>
+ <div
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
+ data-container="body"
+ data-placement="left"
+ :title="collapsedTooltipTitle"
+ >
+ <i
+ v-if="hasNoUsers"
+ aria-label="No Assignee"
+ class="fa fa-user"
+ />
+ <button
+ type="button"
+ class="btn-link"
+ v-for="(user, index) in users"
+ v-if="shouldRenderCollapsedAssignee(index)"
+ >
+ <img
+ width="24"
+ class="avatar avatar-inline s24"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ <span class="author">
+ {{ user.name }}
+ </span>
+ </button>
+ <button
+ v-if="hasMoreThanTwoAssignees"
+ class="btn-link"
+ type="button"
+ >
+ <span
+ class="avatar-counter sidebar-avatar-counter"
+ >
+ {{ sidebarAvatarCounter }}
+ </span>
+ </button>
+ </div>
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value">
+ No assignee
+ <template v-if="editable">
+ -
+ <button
+ type="button"
+ class="btn-link"
+ @click="assignSelf"
+ >
+ assign yourself
+ </button>
+ </template>
+ </span>
+ </template>
+ <template v-else-if="hasOneUser">
+ <a
+ class="author_link bold"
+ :href="assigneeUrl(firstUser)"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(firstUser)"
+ :src="avatarUrl(firstUser)"
+ />
+ <span class="author">
+ {{ firstUser.name }}
+ </span>
+ <span class="username">
+ {{ assigneeUsername(firstUser) }}
+ </span>
+ </a>
+ </template>
+ <template v-else>
+ <div class="user-list">
+ <div
+ class="user-item"
+ v-for="(user, index) in users"
+ v-if="renderAssignee(index)"
+ >
+ <a
+ class="user-link has-tooltip"
+ data-placement="bottom"
+ :href="assigneeUrl(user)"
+ :data-title="user.name"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ </a>
+ </div>
+ </div>
+ <div
+ v-if="renderShowMoreSection"
+ class="user-list-more"
+ >
+ <button
+ type="button"
+ class="btn-link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenAssigneesLabel }}
+ </template>
+ <template v-else>
+ - show less
+ </template>
+ </button>
+ </div>
+ </template>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
new file mode 100644
index 00000000000..1488a66c695
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
@@ -0,0 +1,84 @@
+/* global Flash */
+
+import AssigneeTitle from './assignee_title';
+import Assignees from './assignees';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ loading: false,
+ field: '',
+ };
+ },
+ components: {
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
+ },
+ methods: {
+ assignSelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.assignYourself();
+ this.saveAssignees();
+ },
+ saveAssignees() {
+ this.loading = true;
+
+ function setLoadingFalse() {
+ this.loading = false;
+ }
+
+ this.mediator.saveAssignees(this.field)
+ .then(setLoadingFalse.bind(this))
+ .catch(() => {
+ setLoadingFalse();
+ return new Flash('Error occurred when saving assignees');
+ });
+ },
+ },
+ created() {
+ this.removeAssignee = this.store.removeAssignee.bind(this.store);
+ this.addAssignee = this.store.addAssignee.bind(this.store);
+ this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
+
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeMount() {
+ this.field = this.$el.dataset.field;
+ },
+ template: `
+ <div>
+ <assignee-title
+ :number-of-assignees="store.assignees.length"
+ :loading="loading"
+ :editable="store.editable"
+ />
+ <assignees
+ class="value"
+ :root-path="store.rootPath"
+ :users="store.assignees"
+ :editable="store.editable"
+ @assign-self="assignSelf"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
new file mode 100644
index 00000000000..0da265053bd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
@@ -0,0 +1,97 @@
+import stopwatchSvg from 'icons/_icon_stopwatch.svg';
+
+import '../../../lib/utils/pretty_time';
+
+export default {
+ name: 'time-tracking-collapsed-state',
+ props: {
+ showComparisonState: {
+ type: Boolean,
+ required: true,
+ },
+ showSpentOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showEstimateOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showNoTimeTrackingState: {
+ type: Boolean,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ timeSpent() {
+ return this.abbreviateTime(this.timeSpentHumanReadable);
+ },
+ timeEstimate() {
+ return this.abbreviateTime(this.timeEstimateHumanReadable);
+ },
+ divClass() {
+ if (this.showComparisonState) {
+ return 'compare';
+ } else if (this.showEstimateOnlyState) {
+ return 'estimate-only';
+ } else if (this.showSpentOnlyState) {
+ return 'spend-only';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-tracking';
+ }
+
+ return '';
+ },
+ spanClass() {
+ if (this.showComparisonState) {
+ return '';
+ } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
+ return 'bold';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-value';
+ }
+
+ return '';
+ },
+ text() {
+ if (this.showComparisonState) {
+ return `${this.timeSpent} / ${this.timeEstimate}`;
+ } else if (this.showEstimateOnlyState) {
+ return `-- / ${this.timeEstimate}`;
+ } else if (this.showSpentOnlyState) {
+ return `${this.timeSpent} / --`;
+ } else if (this.showNoTimeTrackingState) {
+ return 'None';
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ abbreviateTime(timeStr) {
+ return gl.utils.prettyTime.abbreviateTime(timeStr);
+ },
+ },
+ template: `
+ <div class="sidebar-collapsed-icon">
+ ${stopwatchSvg}
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
new file mode 100644
index 00000000000..40f5c89c5bb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -0,0 +1,98 @@
+import '../../../lib/utils/pretty_time';
+
+const prettyTime = gl.utils.prettyTime;
+
+export default {
+ name: 'time-tracking-comparison-pane',
+ props: {
+ timeSpent: {
+ type: Number,
+ required: true,
+ },
+ timeEstimate: {
+ type: Number,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parsedRemaining() {
+ const diffSeconds = this.timeEstimate - this.timeSpent;
+ return prettyTime.parseSeconds(diffSeconds);
+ },
+ timeRemainingHumanReadable() {
+ return prettyTime.stringifyTime(this.parsedRemaining);
+ },
+ timeRemainingTooltip() {
+ const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
+ return `${prefix} ${this.timeRemainingHumanReadable}`;
+ },
+ /* Diff values for comparison meter */
+ timeRemainingMinutes() {
+ return this.timeEstimate - this.timeSpent;
+ },
+ timeRemainingPercent() {
+ return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
+ },
+ timeRemainingStatusClass() {
+ return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
+ },
+ /* Parsed time values */
+ parsedEstimate() {
+ return prettyTime.parseSeconds(this.timeEstimate);
+ },
+ parsedSpent() {
+ return prettyTime.parseSeconds(this.timeSpent);
+ },
+ },
+ template: `
+ <div class="time-tracking-comparison-pane">
+ <div
+ class="compare-meter"
+ data-toggle="tooltip"
+ data-placement="top"
+ role="timeRemainingDisplay"
+ :aria-valuenow="timeRemainingTooltip"
+ :title="timeRemainingTooltip"
+ :data-original-title="timeRemainingTooltip"
+ :class="timeRemainingStatusClass"
+ >
+ <div
+ class="meter-container"
+ role="timeSpentPercent"
+ :aria-valuenow="timeRemainingPercent"
+ >
+ <div
+ :style="{ width: timeRemainingPercent }"
+ class="meter-fill"
+ />
+ </div>
+ <div class="compare-display-container">
+ <div class="compare-display pull-left">
+ <span class="compare-label">
+ Spent
+ </span>
+ <span class="compare-value spent">
+ {{ timeSpentHumanReadable }}
+ </span>
+ </div>
+ <div class="compare-display estimated pull-right">
+ <span class="compare-label">
+ Est
+ </span>
+ <span class="compare-value">
+ {{ timeEstimateHumanReadable }}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
new file mode 100644
index 00000000000..ad1b9179db0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -0,0 +1,17 @@
+export default {
+ name: 'time-tracking-estimate-only-pane',
+ props: {
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-estimate-only-pane">
+ <span class="bold">
+ Estimated:
+ </span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
new file mode 100644
index 00000000000..b2a77462fe0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -0,0 +1,44 @@
+export default {
+ name: 'time-tracking-help-state',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ href() {
+ return `${this.rootPath}help/workflow/time_tracking.md`;
+ },
+ },
+ template: `
+ <div class="time-tracking-help-state">
+ <div class="time-tracking-info">
+ <h4>
+ Track time with slash commands
+ </h4>
+ <p>
+ Slash commands can be used in the issues description and comment boxes.
+ </p>
+ <p>
+ <code>
+ /estimate
+ </code>
+ will update the estimated time with the latest command.
+ </p>
+ <p>
+ <code>
+ /spend
+ </code>
+ will update the sum of the time spent.
+ </p>
+ <a
+ class="btn btn-default learn-more-button"
+ :href="href"
+ >
+ Learn more
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
new file mode 100644
index 00000000000..d1dd1dcdd27
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -0,0 +1,10 @@
+export default {
+ name: 'time-tracking-no-tracking-pane',
+ template: `
+ <div class="time-tracking-no-tracking-pane">
+ <span class="no-value">
+ No estimate or time spent
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
new file mode 100644
index 00000000000..244b67b3ad9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -0,0 +1,51 @@
+import '~/smart_interval';
+
+import timeTracker from './time_tracker';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+export default {
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ };
+ },
+ components: {
+ 'issuable-time-tracker': timeTracker,
+ },
+ methods: {
+ listenForSlashCommands() {
+ $(document).on('ajax:success', '.gfm-form', this.slashCommandListened);
+ },
+ slashCommandListened(e, data) {
+ const subscribedCommands = ['spend_time', 'time_estimate'];
+ let changedCommands;
+ if (data !== undefined) {
+ changedCommands = data.commands_changes
+ ? Object.keys(data.commands_changes)
+ : [];
+ } else {
+ changedCommands = [];
+ }
+ if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
+ this.mediator.fetch();
+ }
+ },
+ },
+ mounted() {
+ this.listenForSlashCommands();
+ },
+ template: `
+ <div class="block">
+ <issuable-time-tracker
+ :time_estimate="store.timeEstimate"
+ :time_spent="store.totalTimeSpent"
+ :human_time_estimate="store.humanTimeEstimate"
+ :human_time_spent="store.humanTotalTimeSpent"
+ :rootPath="store.rootPath"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
new file mode 100644
index 00000000000..bf987562647
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
@@ -0,0 +1,15 @@
+export default {
+ name: 'time-tracking-spent-only-pane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
new file mode 100644
index 00000000000..ed0d71a4f79
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
@@ -0,0 +1,163 @@
+import timeTrackingHelpState from './help_state';
+import timeTrackingCollapsedState from './collapsed_state';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import timeTrackingComparisonPane from './comparison_pane';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'issuable-time-tracker',
+ props: {
+ time_estimate: {
+ type: Number,
+ required: true,
+ },
+ time_spent: {
+ type: Number,
+ required: true,
+ },
+ human_time_estimate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ human_time_spent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ components: {
+ 'time-tracking-collapsed-state': timeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ 'time-tracking-comparison-pane': timeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ computed: {
+ timeSpent() {
+ return this.time_spent;
+ },
+ timeEstimate() {
+ return this.time_estimate;
+ },
+ timeEstimateHumanReadable() {
+ return this.human_time_estimate;
+ },
+ timeSpentHumanReadable() {
+ return this.human_time_spent;
+ },
+ hasTimeSpent() {
+ return !!this.timeSpent;
+ },
+ hasTimeEstimate() {
+ return !!this.timeEstimate;
+ },
+ showComparisonState() {
+ return this.hasTimeEstimate && this.hasTimeSpent;
+ },
+ showEstimateOnlyState() {
+ return this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showSpentOnlyState() {
+ return this.hasTimeSpent && !this.hasTimeEstimate;
+ },
+ showNoTimeTrackingState() {
+ return !this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showHelpState() {
+ return !!this.showHelp;
+ },
+ },
+ methods: {
+ toggleHelpState(show) {
+ this.showHelp = show;
+ },
+ update(data) {
+ this.time_estimate = data.time_estimate;
+ this.time_spent = data.time_spent;
+ this.human_time_estimate = data.human_time_estimate;
+ this.human_time_spent = data.human_time_spent;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ template: `
+ <div
+ class="time_tracker time-tracking-component-wrap"
+ v-cloak
+ >
+ <time-tracking-collapsed-state
+ :show-comparison-state="showComparisonState"
+ :show-no-time-tracking-state="showNoTimeTrackingState"
+ :show-help-state="showHelpState"
+ :show-spent-only-state="showSpentOnlyState"
+ :show-estimate-only-state="showEstimateOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <div class="title hide-collapsed">
+ Time tracking
+ <div
+ class="help-button pull-right"
+ v-if="!showHelpState"
+ @click="toggleHelpState(true)"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="close-help-button pull-right"
+ v-if="showHelpState"
+ @click="toggleHelpState(false)"
+ >
+ <i
+ class="fa fa-close"
+ aria-hidden="true"
+ />
+ </div>
+ </div>
+ <div class="time-tracking-content hide-collapsed">
+ <time-tracking-estimate-only-pane
+ v-if="showEstimateOnlyState"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <time-tracking-spent-only-pane
+ v-if="showSpentOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ />
+ <time-tracking-no-tracking-pane
+ v-if="showNoTimeTrackingState"
+ />
+ <time-tracking-comparison-pane
+ v-if="showComparisonState"
+ :time-estimate="timeEstimate"
+ :time-spent="timeSpent"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
+ v-if="showHelpState"
+ :rootPath="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/sidebar/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
new file mode 100644
index 00000000000..5a82d01dc41
--- /dev/null
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class SidebarService {
+ constructor(endpoint) {
+ if (!SidebarService.singleton) {
+ this.endpoint = endpoint;
+
+ SidebarService.singleton = this;
+ }
+
+ return SidebarService.singleton;
+ }
+
+ get() {
+ return Vue.http.get(this.endpoint);
+ }
+
+ update(key, data) {
+ return Vue.http.put(this.endpoint, {
+ [key]: data,
+ }, {
+ emulateJSON: true,
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..2b02af87d8a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import sidebarAssignees from './components/assignees/sidebar_assignees';
+
+import Mediator from './sidebar_mediator';
+
+function domContentLoaded() {
+ const mediator = new Mediator(gl.sidebarOptions);
+ mediator.fetch();
+
+ const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
+
+ // Only create the sidebarAssignees vue app if it is found in the DOM
+ // We currently do not use sidebarAssignees for the MR page
+ if (sidebarAssigneesEl) {
+ new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
+ }
+
+ new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded);
+
+export default domContentLoaded;
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
new file mode 100644
index 00000000000..5ccfb4ee9c1
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -0,0 +1,38 @@
+/* global Flash */
+
+import Service from './services/sidebar_service';
+import Store from './stores/sidebar_store';
+
+export default class SidebarMediator {
+ constructor(options) {
+ if (!SidebarMediator.singleton) {
+ this.store = new Store(options);
+ this.service = new Service(options.endpoint);
+ SidebarMediator.singleton = this;
+ }
+
+ return SidebarMediator.singleton;
+ }
+
+ assignYourself() {
+ this.store.addAssignee(this.store.currentUser);
+ }
+
+ saveAssignees(field) {
+ const selected = this.store.assignees.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ return this.service.update(field, selected.length === 0 ? [0] : selected);
+ }
+
+ fetch() {
+ this.service.get()
+ .then((response) => {
+ const data = response.json();
+ this.store.setAssigneeData(data);
+ this.store.setTimeTrackingData(data);
+ })
+ .catch(() => new Flash('Error occured when fetching sidebar data'));
+ }
+}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
new file mode 100644
index 00000000000..2d44c05bb8d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -0,0 +1,52 @@
+export default class SidebarStore {
+ constructor(store) {
+ if (!SidebarStore.singleton) {
+ const { currentUser, rootPath, editable } = store;
+ this.currentUser = currentUser;
+ this.rootPath = rootPath;
+ this.editable = editable;
+ this.timeEstimate = 0;
+ this.totalTimeSpent = 0;
+ this.humanTimeEstimate = '';
+ this.humanTimeSpent = '';
+ this.assignees = [];
+
+ SidebarStore.singleton = this;
+ }
+
+ return SidebarStore.singleton;
+ }
+
+ setAssigneeData(data) {
+ if (data.assignees) {
+ this.assignees = data.assignees;
+ }
+ }
+
+ setTimeTrackingData(data) {
+ this.timeEstimate = data.time_estimate;
+ this.totalTimeSpent = data.total_time_spent;
+ this.humanTimeEstimate = data.human_time_estimate;
+ this.humanTotalTimeSpent = data.human_total_time_spent;
+ }
+
+ addAssignee(assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(assignee);
+ }
+ }
+
+ findAssignee(findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee(removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees() {
+ this.assignees = [];
+ }
+}
diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js
index d811d1cd53a..2587facc582 100644
--- a/app/assets/javascripts/signin_tabs_memoizer.js
+++ b/app/assets/javascripts/signin_tabs_memoizer.js
@@ -1,5 +1,7 @@
/* eslint no-param-reassign: ["error", { "props": false }]*/
/* eslint no-new: "off" */
+import AccessorUtilities from './lib/utils/accessor';
+
((global) => {
/**
* Memorize the last selected tab after reloading a page.
@@ -9,6 +11,8 @@
constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
this.currentTabKey = currentTabKey;
this.tabSelector = tabSelector;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
this.bootstrap();
}
@@ -37,11 +41,15 @@
}
saveData(val) {
- localStorage.setItem(this.currentTabKey, val);
+ if (!this.isLocalStorageAvailable) return undefined;
+
+ return window.localStorage.setItem(this.currentTabKey, val);
}
readData() {
- return localStorage.getItem(this.currentTabKey);
+ if (!this.isLocalStorageAvailable) return null;
+
+ return window.localStorage.getItem(this.currentTabKey);
}
}
diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js
deleted file mode 100644
index d8191605128..00000000000
--- a/app/assets/javascripts/subbable_resource.js
+++ /dev/null
@@ -1,51 +0,0 @@
-(() => {
-/*
-* SubbableResource can be extended to provide a pubsub-style service for one-off REST
-* calls. Subscribe by passing a callback or render method you will use to handle responses.
- *
-* */
-
- class SubbableResource {
- constructor(resourcePath) {
- this.endpoint = resourcePath;
-
- // TODO: Switch to axios.create
- this.resource = $.ajax;
- this.subscribers = [];
- }
-
- subscribe(callback) {
- this.subscribers.push(callback);
- }
-
- publish(newResponse) {
- const responseCopy = _.extend({}, newResponse);
- this.subscribers.forEach((fn) => {
- fn(responseCopy);
- });
- return newResponse;
- }
-
- get(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- post(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- put(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- delete(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
- }
-
- gl.SubbableResource = SubbableResource;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index 8b25f43ffc7..0cd591c7320 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js
index 754d448564f..32ffa2f0ac0 100644
--- a/app/assets/javascripts/users/calendar.js
+++ b/app/assets/javascripts/users/calendar.js
@@ -168,15 +168,23 @@ import d3 from 'd3';
};
Calendar.prototype.renderKey = function() {
- var keyColors;
- keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
- return this.svg.append('g').attr('transform', "translate(18, " + (this.daySizeWithSpace * 8 + 16) + ")").selectAll('rect').data(keyColors).enter().append('rect').attr('width', this.daySize).attr('height', this.daySize).attr('x', (function(_this) {
- return function(color, i) {
- return _this.daySizeWithSpace * i;
- };
- })(this)).attr('y', 0).attr('fill', function(color) {
- return color;
- });
+ const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions'];
+ const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+
+ this.svg.append('g')
+ .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
+ .selectAll('rect')
+ .data(keyColors)
+ .enter()
+ .append('rect')
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('x', (color, i) => this.daySizeWithSpace * i)
+ .attr('y', 0)
+ .attr('fill', color => color)
+ .attr('class', 'js-tooltip')
+ .attr('title', (color, i) => keyValues[i])
+ .attr('data-container', 'body');
};
Calendar.prototype.initColor = function() {
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 0344ce9ffb4..be29b08c343 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */
/* global Issuable */
-/* global ListUser */
+
+import eventHub from './sidebar/event_hub';
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
@@ -30,7 +31,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 +39,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,43 +52,118 @@
$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();
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(function () {
- $loading.fadeOut();
- })
- .catch(function () {
- $loading.fadeOut();
- });
+ const assignYourself = function () {
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+
+ // Save current selected user to the DOM
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = $dropdown.data('field-name');
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+
+ if (currentUserInfo) {
+ input.value = currentUserInfo.id;
+ input.dataset.meta = currentUserInfo.name;
+ } else if (_this.currentUser) {
+ input.value = _this.currentUser.id;
+ }
+
+ if ($selectbox) {
+ $dropdown.parent().before(input);
+ } else {
+ $dropdown.after(input);
+ }
+ };
+
+ if ($block[0]) {
+ $block[0].addEventListener('assignYourself', assignYourself);
+ }
+
+ const getSelectedUserInputs = function() {
+ return $selectbox
+ .find(`input[name="${$dropdown.data('field-name')}"]`);
+ };
+
+ const getSelected = function() {
+ return getSelectedUserInputs()
+ .map((index, input) => parseInt(input.value, 10))
+ .get();
+ };
+
+ const checkMaxSelect = function() {
+ const maxSelect = $dropdown.data('max-select');
+ if (maxSelect) {
+ const selected = getSelected();
+
+ if (selected.length > maxSelect) {
+ const firstSelectedId = selected[0];
+ const firstSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
+
+ firstSelected.remove();
+ eventHub.$emit('sidebar.removeAssignee', {
+ id: firstSelectedId,
+ });
+ }
+ }
+ };
+
+ const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
+ const selectedUsers = getSelected()
+ .filter(u => u !== 0);
+
+ const firstUser = getSelectedUserInputs()
+ .map((index, input) => ({
+ name: input.dataset.meta,
+ value: parseInt(input.value, 10),
+ }))
+ .filter(u => u.id !== 0)
+ .get(0);
+
+ if (selectedUsers.length === 0) {
+ return 'Unassigned';
+ } else if (selectedUsers.length === 1) {
+ return firstUser.name;
+ } else if (isSelected) {
+ const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
+ return `${selectedUser.name} + ${otherSelected.length} more`;
+ } else {
+ return `${firstUser.name} + ${selectedUsers.length - 1} more`;
+ }
};
$('.assign-to-me-link').on('click', (e) => {
e.preventDefault();
$(e.currentTarget).hide();
- const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
- $input.val(gon.current_user_id);
- selectedId = $input.val();
- $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
- });
- $block.on('click', '.js-assign-yourself', function(e) {
- e.preventDefault();
-
- if ($dropdown.hasClass('js-issue-board-sidebar')) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: _this.currentUser.id,
- username: _this.currentUser.username,
- name: _this.currentUser.name,
- avatar_url: _this.currentUser.avatar_url
- }));
+ if ($dropdown.data('multiSelect')) {
+ assignYourself();
+ checkMaxSelect();
- updateIssueBoardsIssue();
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+ $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
} else {
- return assignTo(_this.currentUser.id);
+ const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
+ $input.val(gon.current_user_id);
+ selectedId = $input.val();
+ $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
}
});
+
+ $block.on('click', '.js-assign-yourself', (e) => {
+ e.preventDefault();
+ return assignTo(_this.currentUser.id);
+ });
+
assignTo = function(selected) {
var data;
data = {};
@@ -95,6 +171,7 @@
data[abilityName].assignee_id = selected != null ? selected : null;
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
+
return $.ajax({
type: 'PUT',
dataType: 'json',
@@ -104,7 +181,6 @@
var user;
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
- $selectbox.hide();
if (data.assignee) {
user = {
name: data.assignee.name,
@@ -131,51 +207,90 @@
var isAuthorFilter;
isAuthorFilter = $('.js-author-search');
return _this.users(term, options, function(users) {
- var anyUser, index, j, len, name, obj, showDivider;
- if (term.length === 0) {
- showDivider = 0;
- if (firstUser) {
- // Move current user to the front of the list
- for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
- obj = users[index];
- if (obj.username === firstUser) {
- users.splice(index, 1);
- users.unshift(obj);
- break;
- }
+ // GitLabDropdownFilter returns this.instance
+ // GitLabDropdownRemote returns this.options.instance
+ const glDropdown = this.instance || this.options.instance;
+ glDropdown.options.processData(term, users, callback);
+ }.bind(this));
+ },
+ processData: function(term, users, callback) {
+ let anyUser;
+ let index;
+ let j;
+ let len;
+ let name;
+ let obj;
+ let showDivider;
+ if (term.length === 0) {
+ showDivider = 0;
+ if (firstUser) {
+ // Move current user to the front of the list
+ for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
+ obj = users[index];
+ if (obj.username === firstUser) {
+ users.splice(index, 1);
+ users.unshift(obj);
+ break;
}
}
- if (showNullUser) {
- showDivider += 1;
- users.unshift({
- beforeDivider: true,
- name: 'Unassigned',
- id: 0
- });
- }
- if (showAnyUser) {
- showDivider += 1;
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- beforeDivider: true,
- name: name,
- id: null
- };
- users.unshift(anyUser);
+ }
+ if (showNullUser) {
+ showDivider += 1;
+ users.unshift({
+ beforeDivider: true,
+ name: 'Unassigned',
+ id: 0
+ });
+ }
+ if (showAnyUser) {
+ showDivider += 1;
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
}
+ anyUser = {
+ beforeDivider: true,
+ name: name,
+ id: null
+ };
+ users.unshift(anyUser);
}
+
if (showDivider) {
- users.splice(showDivider, 0, "divider");
+ users.splice(showDivider, 0, 'divider');
}
- callback(users);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
+ if ($dropdown.hasClass('js-multiselect')) {
+ const selected = getSelected().filter(i => i !== 0);
+
+ if (selected.length > 0) {
+ if ($dropdown.data('dropdown-header')) {
+ showDivider += 1;
+ users.splice(showDivider, 0, {
+ header: $dropdown.data('dropdown-header'),
+ });
+ }
+
+ const selectedUsers = users
+ .filter(u => selected.indexOf(u.id) !== -1)
+ .sort((a, b) => a.name > b.name);
+
+ users = users.filter(u => selected.indexOf(u.id) === -1);
+
+ selectedUsers.forEach((selectedUser) => {
+ showDivider += 1;
+ users.splice(showDivider, 0, selectedUser);
+ });
+
+ users.splice(showDivider + 1, 0, 'divider');
+ }
}
- });
+ }
+
+ callback(users);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
},
filterable: true,
filterRemote: true,
@@ -184,33 +299,110 @@
},
selectable: true,
fieldName: $dropdown.data('field-name'),
- toggleLabel: function(selected, el) {
+ toggleLabel: function(selected, el, glDropdown) {
+ const inputValue = glDropdown.filterInput.val();
+
+ if (this.multiSelect && inputValue === '') {
+ // Remove non-users from the fullData array
+ const users = glDropdown.filteredFullData();
+ const callback = glDropdown.parseData.bind(glDropdown);
+
+ // Update the data model
+ this.processData(inputValue, users, callback);
+ }
+
+ if (this.multiSelect) {
+ return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
+ }
+
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;
}
},
defaultLabel: defaultLabel,
- inputId: 'issue_assignee_id',
hidden: function(e) {
- $selectbox.hide();
- // display:block overrides the hide-collapse rule
- return $value.css('display', '');
+ if ($dropdown.hasClass('js-multiselect')) {
+ eventHub.$emit('sidebar.saveAssignees');
+ }
+
+ if (!$dropdown.data('always-show-selectbox')) {
+ $selectbox.hide();
+
+ // Recalculate where .value is because vue might have changed it
+ $block = $selectbox.closest('.block');
+ $value = $block.find('.value');
+ // display:block overrides the hide-collapse rule
+ $value.css('display', '');
+ }
},
- vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(user, $el, e) {
+ multiSelect: $dropdown.hasClass('js-multiselect'),
+ inputMeta: $dropdown.data('input-meta'),
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const user = options.selectedObj;
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const isActive = $el.hasClass('is-active');
+ const previouslySelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]");
+
+ // Enables support for limiting the number of users selected
+ // Automatically removes the first on the list if more users are selected
+ checkMaxSelect();
+
+ if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
+ // Unassigned selected
+ previouslySelected.each((index, element) => {
+ const id = parseInt(element.value, 10);
+ element.remove();
+ });
+ eventHub.$emit('sidebar.removeAllAssignees');
+ } else if (isActive) {
+ // user selected
+ eventHub.$emit('sidebar.addAssignee', user);
+
+ // Remove unassigned selection (if it was previously selected)
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+ } else {
+ if (previouslySelected.length === 0) {
+ // Select unassigned because there is no more selected users
+ this.addInput($dropdown.data('field-name'), 0, {});
+ }
+
+ // User unselected
+ eventHub.$emit('sidebar.removeAssignee', user);
+ }
+
+ if (getSelected().find(u => u === gon.current_user_id)) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ }
+
var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
- selectedId = user.id;
+
+ const isSelecting = (user.id !== selectedId);
+ selectedId = isSelecting ? user.id : selectedIdDefault;
+
if (selectedId === gon.current_user_id) {
$('.assign-to-me-link').hide();
} else {
@@ -221,24 +413,10 @@
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) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: user.id,
- username: user.username,
- name: user.name,
- avatar_url: user.avatar_url
- }));
- } else {
- gl.issueBoards.boardStoreIssueDelete('assignee');
- }
-
- updateIssueBoardsIssue();
- } else {
+ } else if (!$dropdown.hasClass('js-multiselect')) {
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
return assignTo(selected);
}
@@ -248,30 +426,58 @@
},
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');
+
+ function highlightSelected(id) {
+ $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
+ }
+
+ if ($selectbox[0]) {
+ getSelected().forEach(selectedId => highlightSelected(selectedId));
+ } else {
+ highlightSelected(selectedId);
+ }
},
+ updateLabel: $dropdown.data('dropdown-title'),
renderRow: function(user) {
- var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
+ var avatar, img, listClosingTags, listWithName, listWithUserName, username;
username = user.username ? "@" + user.username : "";
avatar = user.avatar_url ? user.avatar_url : false;
- selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
+
+ let selected = user.id === parseInt(selectedId, 10);
+
+ if (this.multiSelect) {
+ const fieldName = this.fieldName;
+ const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
+
+ if (field.length) {
+ selected = true;
+ }
+ }
+
img = "";
if (user.beforeDivider != null) {
- "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
} else {
if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />";
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
}
}
- // split into three parts so we can remove the username section if nessesary
- listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
- listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
- listClosingTags = "</a> </li>";
- if (username === '') {
- listWithUserName = '';
- }
- return listWithName + listWithUserName + listClosingTags;
+
+ return `
+ <li data-user-id=${user.id}>
+ <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
+ ${img}
+ <strong class='dropdown-menu-user-full-name'>
+ ${user.name}
+ </strong>
+ ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
+ </a>
+ </li>
+ `;
}
});
};
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/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js
new file mode 100644
index 00000000000..f83c4b00761
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/translate.js
@@ -0,0 +1,42 @@
+import {
+ __,
+ n__,
+ s__,
+} from '../locale';
+
+export default (Vue) => {
+ Vue.mixin({
+ methods: {
+ /**
+ Translates `text`
+
+ @param text The text to be translated
+ @returns {String} The translated text
+ **/
+ __,
+ /**
+ Translate the text with a number
+ if the number is more than 1 it will use the `pluralText` translation.
+ This method allows for contexts, see below re. contexts
+
+ @param text Singular text to translate (eg. '%d day')
+ @param pluralText Plural text to translate (eg. '%d days')
+ @param count Number to decide which translation to use (eg. 2)
+ @returns {String} Translated text with the number replaced (eg. '2 days')
+ **/
+ n__,
+ /**
+ Translate context based text
+ Either pass in the context translation like `Context|Text to translate`
+ or allow for dynamic text by doing passing in the context first & then the text to translate
+
+ @param keyOrContext Can be either the key to translate including the context
+ (eg. 'Context|Text') or just the context for the translation
+ (eg. 'Context')
+ @param key Is the dynamic variable you want to be translated
+ @returns {String} Translated context based text
+ **/
+ s__,
+ },
+ });
+};
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 7c50b80fd2b..3cd7f81da47 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -159,3 +159,31 @@ a {
.fade-in {
animation: fadeIn $fade-in-duration 1;
}
+
+@keyframes fadeInHalf {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 0.5;
+ }
+}
+
+.fade-in-half {
+ animation: fadeInHalf $fade-in-duration 1;
+}
+
+@keyframes fadeInFull {
+ 0% {
+ opacity: 0.5;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+.fade-in-full {
+ animation: fadeInFull $fade-in-duration 1;
+}
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 3f5b78ed445..91c1ebd5a7d 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -93,3 +93,14 @@
align-self: center;
}
}
+
+.avatar-counter {
+ background-color: $gray-darkest;
+ color: $white-light;
+ border: 1px solid $border-color;
+ border-radius: 1em;
+ font-family: $regular_font;
+ font-size: 9px;
+ line-height: 16px;
+ text-align: center;
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 1313ea25c2a..5c9b71a452c 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -251,14 +251,16 @@
}
.dropdown-header {
- color: $gl-text-color;
+ color: $gl-text-color-secondary;
font-size: 13px;
- font-weight: 600;
line-height: 22px;
- text-transform: capitalize;
padding: 0 16px;
}
+ &.capitalize-header .dropdown-header {
+ text-transform: capitalize;
+ }
+
.separator + .dropdown-header {
padding-top: 2px;
}
@@ -337,8 +339,8 @@
.dropdown-menu-user {
.avatar {
float: left;
- width: 30px;
- height: 30px;
+ width: 2 * $gl-padding;
+ height: 2 * $gl-padding;
margin: 0 10px 0 0;
}
}
@@ -381,6 +383,7 @@
.dropdown-menu-selectable {
a {
padding-left: 26px;
+ position: relative;
&.is-indeterminate,
&.is-active {
@@ -390,7 +393,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;
@@ -405,6 +409,9 @@
&.is-active::before {
content: "\f00c";
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
}
}
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index c197bf6b9f5..1dd0e5ab581 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -162,6 +162,18 @@
&.code {
padding: 0;
}
+
+ .list-inline.previews {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-content: flex-start;
+ align-items: baseline;
+
+ .preview {
+ padding: $gl-padding;
+ }
+ }
}
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 0692f65043b..e624d0d951e 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -114,11 +114,21 @@
padding-right: 8px;
.fa-close {
- color: $gl-text-color-disabled;
+ color: $gl-text-color-secondary;
}
&:hover .fa-close {
- color: $gl-text-color-secondary;
+ color: $gl-text-color;
+ }
+
+ &.inverted {
+ .fa-close {
+ color: $gl-text-color-secondary-inverted;
+ }
+
+ &:hover .fa-close {
+ color: $gl-text-color-inverted;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 15dc0aa6a52..c9a25946ffd 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -255,6 +255,7 @@ ul.controls {
.avatar-inline {
margin-left: 0;
margin-right: 0;
+ margin-bottom: 0;
}
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 49741c963df..08bcb582613 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -101,6 +101,8 @@ $gl-font-size: 14px;
$gl-text-color: rgba(0, 0, 0, .85);
$gl-text-color-secondary: rgba(0, 0, 0, .55);
$gl-text-color-disabled: rgba(0, 0, 0, .35);
+$gl-text-color-inverted: rgba(255, 255, 255, 1.0);
+$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
$gl-text-green: $green-600;
$gl-text-red: $red-500;
$gl-text-orange: $orange-600;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 0be1c215959..68d7ab4bf84 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -207,8 +207,13 @@
margin-bottom: 5px;
}
- &.is-active {
+ &.is-active,
+ &.is-active .card-assignee:hover a {
background-color: $row-hover;
+
+ &:first-child:not(:only-child) {
+ box-shadow: -10px 0 10px 1px $row-hover;
+ }
}
.label {
@@ -224,7 +229,7 @@
}
.card-title {
- margin: 0;
+ margin: 0 30px 0 0;
font-size: 1em;
line-height: inherit;
@@ -240,10 +245,69 @@
min-height: 20px;
.card-assignee {
- margin-left: auto;
- margin-right: 5px;
- padding-left: 10px;
+ display: flex;
+ justify-content: flex-end;
+ position: absolute;
+ right: 15px;
height: 20px;
+ width: 20px;
+
+ .avatar-counter {
+ display: none;
+ vertical-align: middle;
+ min-width: 20px;
+ line-height: 19px;
+ height: 20px;
+ padding-left: 2px;
+ padding-right: 2px;
+ border-radius: 2em;
+ }
+
+ img {
+ vertical-align: top;
+ }
+
+ a {
+ position: relative;
+ margin-left: -15px;
+ }
+
+ a:nth-child(1) {
+ z-index: 3;
+ }
+
+ a:nth-child(2) {
+ z-index: 2;
+ }
+
+ a:nth-child(3) {
+ z-index: 1;
+ }
+
+ a:nth-child(4) {
+ display: none;
+ }
+
+ &:hover {
+ .avatar-counter {
+ display: inline-block;
+ }
+
+ a {
+ position: static;
+ background-color: $white-light;
+ transition: background-color 0s;
+ margin-left: auto;
+
+ &:nth-child(4) {
+ display: block;
+ }
+
+ &:first-child:not(:only-child) {
+ box-shadow: -10px 0 10px 1px $white-light;
+ }
+ }
+ }
}
.avatar {
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 403724cd68a..d29944207c5 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -3,6 +3,25 @@
margin: 24px auto 0;
position: relative;
+ .landing {
+ margin-top: 10px;
+
+ .inner-content {
+ white-space: normal;
+
+ h4,
+ p {
+ margin: 7px 0 0;
+ max-width: 480px;
+ padding: 0 $gl-padding;
+
+ @media (max-width: $screen-sm-min) {
+ margin: 0 auto;
+ }
+ }
+ }
+ }
+
.col-headers {
ul {
margin: 0;
@@ -175,7 +194,7 @@
}
.stage-nav-item {
- display: block;
+ display: flex;
line-height: 65px;
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
@@ -209,14 +228,10 @@
}
.stage-nav-item-cell {
- float: left;
-
- &.stage-name {
- width: 65%;
- }
-
&.stage-median {
- width: 35%;
+ margin-left: auto;
+ margin-right: $gl-padding;
+ min-width: calc(35% - #{$gl-padding});
}
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 1b4694377b3..77f2638683a 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;
}
@@ -576,14 +570,7 @@
.diff-comments-more-count,
.diff-notes-collapse {
- background-color: $gray-darkest;
- color: $white-light;
- border: 1px solid $white-light;
- border-radius: 1em;
- font-family: $regular_font;
- font-size: 9px;
- line-height: 17px;
- text-align: center;
+ @extend .avatar-counter;
}
.diff-notes-collapse {
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..c4210ffd823 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;
}
}
@@ -90,10 +95,15 @@
}
.right-sidebar {
- a {
+ a,
+ .btn-link {
color: inherit;
}
+ .btn-link {
+ outline: none;
+ }
+
.issuable-header-text {
margin-top: 7px;
}
@@ -210,6 +220,10 @@
}
}
+ .assign-yourself .btn-link {
+ padding-left: 0;
+ }
+
.light {
font-weight: normal;
}
@@ -234,6 +248,10 @@
margin-left: 0;
}
+ .assignee .user-list .avatar {
+ margin: 0;
+ }
+
.username {
display: block;
margin-top: 4px;
@@ -296,6 +314,10 @@
margin-top: 0;
}
+ .sidebar-avatar-counter {
+ padding-top: 2px;
+ }
+
.todo-undone {
color: $gl-link-color;
}
@@ -304,10 +326,15 @@
display: none;
}
- .avatar:hover {
+ .avatar:hover,
+ .avatar-counter:hover {
border-color: $issuable-sidebar-color;
}
+ .avatar-counter:hover {
+ color: $issuable-sidebar-color;
+ }
+
.btn-clipboard {
border: none;
color: $issuable-sidebar-color;
@@ -317,6 +344,17 @@
color: $gl-text-color;
}
}
+
+ &.multiple-users {
+ display: flex;
+ justify-content: center;
+ }
+ }
+
+ .sidebar-avatar-counter {
+ width: 24px;
+ height: 24px;
+ border-radius: 12px;
}
.sidebar-collapsed-user {
@@ -327,6 +365,37 @@
.issuable-header-btn {
display: none;
}
+
+ .multiple-users {
+ height: 24px;
+ margin-bottom: 17px;
+ margin-top: 4px;
+ padding-bottom: 4px;
+
+ .btn-link {
+ padding: 0;
+ border: 0;
+
+ .avatar {
+ margin: 0;
+ }
+ }
+
+ .btn-link:first-child {
+ position: absolute;
+ left: 10px;
+ z-index: 1;
+ }
+
+ .btn-link:last-child {
+ position: absolute;
+ right: 10px;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
}
a {
@@ -378,6 +447,12 @@
margin: -5px;
}
+
+.user-list {
+ display: flex;
+ flex-wrap: wrap;
+}
+
.participants-author {
display: inline-block;
padding: 5px;
@@ -395,13 +470,39 @@
}
}
-.participants-more {
+.user-item {
+ display: inline-block;
+ padding: 5px;
+ flex-basis: 20%;
+
+ .user-link {
+ display: inline-block;
+ }
+}
+
+.participants-more,
+.user-list-more {
margin-top: 5px;
margin-left: 5px;
- a {
+ a,
+ .btn-link {
color: $gl-text-color-secondary;
}
+
+ .btn-link {
+ outline: none;
+ padding: 0;
+ }
+
+ .btn-link:hover {
+ @extend a:hover;
+ text-decoration: none;
+ }
+
+ .btn-link:focus {
+ text-decoration: none;
+ }
}
.issuable-form-padding-top {
@@ -494,6 +595,19 @@
}
}
+.issuable-list li,
+.issue-info-container .controls {
+ .avatar-counter {
+ display: inline-block;
+ vertical-align: middle;
+ min-width: 16px;
+ line-height: 14px;
+ height: 16px;
+ padding-left: 2px;
+ padding-right: 2px;
+ }
+}
+
.time_tracker {
padding-bottom: 0;
border-bottom: 0;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 2aa52986e0a..ad3b6e0344b 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -18,6 +18,15 @@
}
}
+.issue-realtime-pre-pulse {
+ opacity: 0;
+}
+
+.issue-realtime-trigger-pulse {
+ transition: opacity $fade-in-duration linear;
+ opacity: 1;
+}
+
.check-all-holder {
line-height: 36px;
float: left;
@@ -161,3 +170,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/labels.scss b/app/assets/stylesheets/pages/labels.scss
index e1ef0b029a5..c10588ac58e 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -116,7 +116,7 @@
}
.manage-labels-list {
- > li:not(.empty-message) {
+ > li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
cursor: move;
cursor: -webkit-grab;
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..69c328d09ff 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -57,6 +57,25 @@ ul.notes {
position: relative;
border-bottom: 1px solid $white-normal;
+ &.being-posted {
+ pointer-events: none;
+ opacity: 0.5;
+
+ .dummy-avatar {
+ display: inline-block;
+ height: 40px;
+ width: 40px;
+ border-radius: 50%;
+ background-color: $kdb-border;
+ border: 1px solid darken($kdb-border, 25%);
+ }
+
+ .note-headline-light,
+ .fa-spinner {
+ margin-left: 3px;
+ }
+ }
+
&.note-discussion {
&.timeline-entry {
padding: 14px 10px;
@@ -67,7 +86,7 @@ ul.notes {
}
}
- &.is-editting {
+ &.is-editing {
.note-header,
.note-text,
.edited-text {
@@ -111,12 +130,6 @@ ul.notes {
}
.note-header {
- padding-bottom: 8px;
- padding-right: 20px;
-
- @media (min-width: $screen-sm-min) {
- padding-right: 0;
- }
@media (max-width: $screen-xs-min) {
.inline {
@@ -365,10 +378,15 @@ ul.notes {
.note-header {
display: flex;
justify-content: space-between;
+
+ @media (max-width: $screen-xs-max) {
+ flex-flow: row wrap;
+ }
}
.note-header-info {
min-width: 0;
+ padding-bottom: 5px;
}
.note-headline-light {
@@ -416,6 +434,11 @@ ul.notes {
margin-left: 10px;
color: $gray-darkest;
+ @media (max-width: $screen-xs-max) {
+ float: none;
+ margin-left: 0;
+ }
+
.note-action-button {
margin-left: 8px;
}
@@ -687,6 +710,10 @@ ul.notes {
}
}
+.discussion-notes .flash-container {
+ margin-bottom: 0;
+}
+
// Merge request notes in diffs
.diff-file {
// Diff is side by side
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index a4fe652b52f..530a6f3c6a1 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -273,6 +273,7 @@
.stage-container {
display: inline-block;
position: relative;
+ vertical-align: middle;
height: 22px;
margin: 3px 6px 3px 0;
@@ -316,6 +317,32 @@
}
}
+.build-failures {
+ .build-state {
+ padding: 20px 2px;
+
+ .build-name {
+ float: right;
+ font-weight: 500;
+ }
+
+ .ci-status-icon-failed svg {
+ vertical-align: middle;
+ }
+
+ .stage {
+ color: $gl-text-color-secondary;
+ font-weight: 500;
+ vertical-align: middle;
+ }
+ }
+
+ .build-log {
+ border: none;
+ line-height: initial;
+ }
+}
+
// Pipeline graph
.pipeline-graph {
width: 100%;
@@ -781,16 +808,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 +915,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/todos.scss b/app/assets/stylesheets/pages/todos.scss
index a39815319f3..de652a79369 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -54,8 +54,9 @@
background-color: $white-light;
&:hover {
- border-color: $white-dark;
+ border-color: $white-normal;
background-color: $gray-light;
+ border-top: 1px solid transparent;
.todo-avatar,
.todo-item {
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/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 643993d035e..152d7baad49 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -133,6 +133,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:signup_enabled,
:sentry_dsn,
:sentry_enabled,
+ :clientside_sentry_dsn,
+ :clientside_sentry_enabled,
:send_user_confirmation_email,
:shared_runners_enabled,
:shared_runners_text,
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 37a1a23178e..4c3d336b3af 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -16,6 +16,8 @@ class Admin::ServicesController < Admin::ApplicationController
def update
if service.update_attributes(service_params[:service])
+ PropagateServiceTemplateWorker.perform_async(service.id) if service.active?
+
redirect_to admin_application_settings_services_path,
notice: 'Application settings saved successfully'
else
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e48f0963ef4..65a1f640a76 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
+ around_action :set_locale
+
protect_from_forgery with: :exception
helper_method :can?, :current_application_settings
@@ -56,7 +58,7 @@ class ApplicationController < ActionController::Base
if current_user
not_found
else
- redirect_to new_user_session_path
+ authenticate_user!
end
end
@@ -269,4 +271,12 @@ class ApplicationController < ActionController::Base
def u2f_app_id
request.base_url
end
+
+ def set_locale
+ Gitlab::I18n.set_locale(current_user)
+
+ yield
+ ensure
+ Gitlab::I18n.reset_locale
+ end
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 3ccf2a9ce33..b199f18da1e 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -66,6 +66,7 @@ module IssuableActions
:milestone_id,
:state_event,
:subscription_event,
+ assignee_ids: [],
label_ids: [],
add_label_ids: [],
remove_label_ids: []
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index c8a501d7319..6df2c068745 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -43,7 +43,7 @@ module IssuableCollections
end
def issues_collection
- issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace)
+ issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
end
def merge_requests_collection
diff --git a/app/controllers/concerns/markdown_preview.rb b/app/controllers/concerns/markdown_preview.rb
deleted file mode 100644
index 40eff267348..00000000000
--- a/app/controllers/concerns/markdown_preview.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-module MarkdownPreview
- private
-
- def render_markdown_preview(text, markdown_context = {})
- render json: {
- body: view_context.markdown(text, markdown_context),
- references: {
- users: preview_referenced_users(text)
- }
- }
- end
-
- def preview_referenced_users(text)
- extractor = Gitlab::ReferenceExtractor.new(@project, current_user)
- extractor.analyze(text, author: current_user)
-
- extractor.users.map(&:username)
- end
-end
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
index c32038d07bf..a57d9e6e6c0 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -65,6 +65,15 @@ module NotesActions
private
+ def note_html(note)
+ render_to_string(
+ "shared/notes/_note",
+ layout: false,
+ formats: [:html],
+ locals: { note: note }
+ )
+ end
+
def note_json(note)
attrs = {
commands_changes: note.commands_changes
@@ -98,6 +107,41 @@ module NotesActions
attrs
end
+ def diff_discussion_html(discussion)
+ return unless discussion.diff_discussion?
+
+ if params[:view] == 'parallel'
+ template = "discussions/_parallel_diff_discussion"
+ locals =
+ if params[:line_type] == 'old'
+ { discussions_left: [discussion], discussions_right: nil }
+ else
+ { discussions_left: nil, discussions_right: [discussion] }
+ end
+ else
+ template = "discussions/_diff_discussion"
+ locals = { discussions: [discussion] }
+ end
+
+ render_to_string(
+ template,
+ layout: false,
+ formats: [:html],
+ locals: locals
+ )
+ end
+
+ def discussion_html(discussion)
+ return if discussion.individual_note?
+
+ render_to_string(
+ "discussions/_discussion",
+ layout: false,
+ formats: [:html],
+ locals: { discussion: discussion }
+ )
+ end
+
def authorize_admin_note!
return access_denied! unless can?(current_user, :admin_note, note)
end
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
new file mode 100644
index 00000000000..d4ab6782444
--- /dev/null
+++ b/app/controllers/concerns/routable_actions.rb
@@ -0,0 +1,38 @@
+module RoutableActions
+ extend ActiveSupport::Concern
+
+ def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
+ routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
+
+ if routable_authorized?(routable_klass, routable, extra_authorization_proc)
+ ensure_canonical_path(routable, requested_full_path)
+ routable
+ else
+ route_not_found
+ nil
+ end
+ end
+
+ def routable_authorized?(routable_klass, routable, extra_authorization_proc)
+ action = :"read_#{routable_klass.to_s.underscore}"
+ return false unless can?(current_user, action, routable)
+
+ if extra_authorization_proc
+ extra_authorization_proc.call(routable)
+ else
+ true
+ end
+ end
+
+ def ensure_canonical_path(routable, requested_path)
+ return unless request.get?
+
+ canonical_path = routable.full_path
+ if canonical_path != requested_path
+ if canonical_path.casecmp(requested_path) != 0
+ flash[:notice] = "Project '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path."
+ end
+ redirect_to request.original_url.sub(requested_path, canonical_path)
+ end
+ end
+end
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/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb
index d5031da867a..dd1d46a68c7 100644
--- a/app/controllers/dashboard/labels_controller.rb
+++ b/app/controllers/dashboard/labels_controller.rb
@@ -3,7 +3,7 @@ class Dashboard::LabelsController < Dashboard::ApplicationController
labels = LabelsFinder.new(current_user).execute
respond_to do |format|
- format.json { render json: labels.as_json(only: [:id, :title, :color]) }
+ format.json { render json: LabelSerializer.new.represent_appearance(labels) }
end
end
end
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 29ffaeb19c1..afffb813b44 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -1,4 +1,6 @@
class Groups::ApplicationController < ApplicationController
+ include RoutableActions
+
layout 'group'
skip_before_action :authenticate_user!
@@ -7,29 +9,17 @@ class Groups::ApplicationController < ApplicationController
private
def group
- unless @group
- id = params[:group_id] || params[:id]
- @group = Group.find_by_full_path(id)
- @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute
-
- unless @group && can?(current_user, :read_group, @group)
- @group = nil
-
- if current_user.nil?
- authenticate_user!
- else
- render_404
- end
- end
- end
-
- @group
+ @group ||= find_routable!(Group, params[:group_id] || params[:id])
end
def group_projects
@projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end
+ def group_merge_requests
+ @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute
+ end
+
def authorize_admin_group!
unless can?(current_user, :admin_group, group)
return render_404
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index facb25525b5..3fa0516fb0c 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -15,7 +15,7 @@ class Groups::LabelsController < Groups::ApplicationController
format.json do
available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
- render json: available_labels.as_json(only: [:id, :title, :color])
+ render json: LabelSerializer.new.represent_appearance(available_labels)
end
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/groups_controller.rb b/app/controllers/groups_controller.rb
index 593001e6396..46c3ff10694 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -12,8 +12,8 @@ class GroupsController < Groups::ApplicationController
before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects]
before_action :authorize_create_group!, only: [:new, :create]
- # Load group projects
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
+ before_action :group_merge_requests, only: [:merge_requests]
before_action :event_filter, only: [:activity]
before_action :user_actions, only: [:show, :subgroups]
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 58d50ad647b..2a8c8ca4bad 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -67,7 +67,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def omniauth_error
@provider = params[:provider]
@error = params[:error]
- render 'errors/omniauth_error', layout: "errors", status: 422
+ render 'errors/omniauth_error', layout: "oauth_error", status: 422
end
def cas3
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 987b95e89b9..57e23cea00e 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -85,7 +85,8 @@ class ProfilesController < Profiles::ApplicationController
:twitter,
:username,
:website_url,
- :organization
+ :organization,
+ :preferred_language
)
end
end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index e2f81b09adc..b4b0dfc3eb8 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -1,5 +1,8 @@
class Projects::ApplicationController < ApplicationController
+ include RoutableActions
+
skip_before_action :authenticate_user!
+ before_action :redirect_git_extension
before_action :project
before_action :repository
layout 'project'
@@ -8,40 +11,22 @@ class Projects::ApplicationController < ApplicationController
private
+ def redirect_git_extension
+ # Redirect from
+ # localhost/group/project.git
+ # to
+ # localhost/group/project
+ #
+ redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git'
+ end
+
def project
- unless @project
- namespace = params[:namespace_id]
- id = params[:project_id] || params[:id]
-
- # Redirect from
- # localhost/group/project.git
- # to
- # localhost/group/project
- #
- if params[:format] == 'git'
- redirect_to request.original_url.gsub(/\.git\/?\Z/, '')
- return
- end
-
- project_path = "#{namespace}/#{id}"
- @project = Project.find_by_full_path(project_path)
-
- if can?(current_user, :read_project, @project) && !@project.pending_delete?
- if @project.path_with_namespace != project_path
- redirect_to request.original_url.gsub(project_path, @project.path_with_namespace)
- end
- else
- @project = nil
-
- if current_user.nil?
- authenticate_user!
- else
- render_404
- end
- end
- end
+ return @project if @project
- @project
+ path = File.join(params[:namespace_id], params[:project_id] || params[:id])
+ auth_proc = ->(project) { !project.pending_delete? }
+
+ @project = find_routable!(Project, path, extra_authorization_proc: auth_proc)
end
def repository
@@ -89,4 +74,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/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
index 28c9646910d..da9b789d617 100644
--- a/app/controllers/projects/boards/issues_controller.rb
+++ b/app/controllers/projects/boards/issues_controller.rb
@@ -82,7 +82,7 @@ module Projects
labels: true,
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: {
- assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
+ assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
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/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 10adddb4636..9e4edcae101 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -59,7 +59,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
def render_ok
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.git_http_ok(repository, user, action_name)
+ render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
end
def render_http_not_allowed
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index cbf67137261..bcd23d61519 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
@@ -64,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController
def new
params[:issue] ||= ActionController::Parameters.new(
- assignee_id: ""
+ assignee_ids: ""
)
build_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -147,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController
if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short],
include: { milestone: {},
- assignee: { only: [:name, :username], methods: [:avatar_url] },
+ assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } })
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
@@ -191,14 +194,33 @@ 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
def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- render json: { title: view_context.markdown_field(@issue, :title) }
+
+ render json: {
+ title: view_context.markdown_field(@issue, :title),
+ title_text: @issue.title,
+ description: view_context.markdown_field(@issue, :description),
+ description_text: @issue.description,
+ task_status: @issue.task_status,
+ issue_number: @issue.iid,
+ updated_at: @issue.updated_at,
+ }
+ 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
@@ -224,6 +246,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
@@ -258,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController
def issue_params
params.require(:issue).permit(
:title, :assignee_id, :position, :description, :confidential,
- :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: []
+ :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [],
)
end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 2f55ba4e700..71bfb7163da 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -19,7 +19,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: @available_labels.as_json(only: [:id, :title, :color])
+ render json: LabelSerializer.new.represent_appearance(@available_labels)
end
end
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 37f51b2ebe3..41a13f6f577 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -62,50 +62,6 @@ class Projects::NotesController < Projects::ApplicationController
end
alias_method :awardable, :note
- def note_html(note)
- render_to_string(
- "shared/notes/_note",
- layout: false,
- formats: [:html],
- locals: { note: note }
- )
- end
-
- def discussion_html(discussion)
- return if discussion.individual_note?
-
- render_to_string(
- "discussions/_discussion",
- layout: false,
- formats: [:html],
- locals: { discussion: discussion }
- )
- end
-
- def diff_discussion_html(discussion)
- return unless discussion.diff_discussion?
-
- if params[:view] == 'parallel'
- template = "discussions/_parallel_diff_discussion"
- locals =
- if params[:line_type] == 'old'
- { discussions_left: [discussion], discussions_right: nil }
- else
- { discussions_left: nil, discussions_right: [discussion] }
- end
- else
- template = "discussions/_diff_discussion"
- locals = { discussions: [discussion] }
- end
-
- render_to_string(
- template,
- layout: false,
- formats: [:html],
- locals: locals
- )
- end
-
def finder_params
params.merge(last_fetched_at: last_fetched_at)
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..5cb2e428201 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -1,27 +1,31 @@
class Projects::PipelinesController < Projects::ApplicationController
before_action :pipeline, except: [:index, :new, :create, :charts]
- before_action :commit, only: [:show, :builds]
+ before_action :commit, only: [:show, :builds, :failures]
before_action :authorize_read_pipeline!
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :builds_enabled, only: :charts
+ wrap_parameters Ci::Pipeline
+
+ POLLING_INTERVAL = 10_000
+
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
@@ -29,7 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
+ Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
render json: {
pipelines: PipelineSerializer
@@ -55,22 +59,36 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipeline = Ci::CreatePipelineService
.new(project, current_user, create_params)
.execute(ignore_skip_ci: true, save_on_errors: false)
- unless @pipeline.persisted?
+
+ if @pipeline.persisted?
+ redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
+ else
render 'new'
- return
end
-
- redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
end
def show
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
+
+ render json: PipelineSerializer
+ .new(project: @project, user: @current_user)
+ .represent(@pipeline, grouped: true)
+ end
+ end
end
def builds
- respond_to do |format|
- format.html do
- render 'show'
- end
+ render_show
+ end
+
+ def failures
+ if @pipeline.statuses.latest.failed.present?
+ render_show
+ else
+ redirect_to pipeline_path(@pipeline)
end
end
@@ -92,13 +110,25 @@ class Projects::PipelinesController < Projects::ApplicationController
def retry
pipeline.retry_failed(current_user)
- redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ respond_to do |format|
+ format.html do
+ redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ end
+
+ format.json { head :no_content }
+ end
end
def cancel
pipeline.cancel_running
- redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ respond_to do |format|
+ format.html do
+ redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ end
+
+ format.json { head :no_content }
+ end
end
def charts
@@ -111,6 +141,14 @@ class Projects::PipelinesController < Projects::ApplicationController
private
+ def render_show
+ respond_to do |format|
+ format.html do
+ render 'show'
+ end
+ end
+ end
+
def create_params
params.require(:pipeline).permit(:ref)
end
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/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 96125684da0..887d18dbec3 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -1,6 +1,4 @@
class Projects::WikisController < Projects::ApplicationController
- include MarkdownPreview
-
before_action :authorize_read_wiki!
before_action :authorize_create_wiki!, only: [:edit, :create, :history]
before_action :authorize_admin_wiki!, only: :destroy
@@ -97,9 +95,14 @@ class Projects::WikisController < Projects::ApplicationController
end
def preview_markdown
- context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
-
- render_markdown_preview(params[:text], context)
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ render json: {
+ body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]),
+ references: {
+ users: result[:users]
+ }
+ }
end
private
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 9f6ee4826e6..69310b26e76 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -1,7 +1,6 @@
class ProjectsController < Projects::ApplicationController
include IssuableCollections
include ExtractsPath
- include MarkdownPreview
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
before_action :project, except: [:index, :new, :create]
@@ -240,7 +239,15 @@ class ProjectsController < Projects::ApplicationController
end
def preview_markdown
- render_markdown_preview(params[:text])
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ render json: {
+ body: view_context.markdown(result[:text]),
+ references: {
+ users: result[:users],
+ commands: view_context.markdown(result[:commands])
+ }
+ }
end
private
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index 3c4ddc1680d..f9496787b15 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -13,15 +13,6 @@ class Snippets::NotesController < ApplicationController
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
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index da1ae9a34d9..19e07e3ab86 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -3,7 +3,6 @@ class SnippetsController < ApplicationController
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
- include MarkdownPreview
include RendersBlob
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
@@ -65,6 +64,7 @@ class SnippetsController < ApplicationController
blob = @snippet.blob
override_max_blob_size(blob)
+ @note = Note.new(noteable: @snippet)
@noteable = @snippet
@discussions = @snippet.discussions
@@ -90,7 +90,14 @@ class SnippetsController < ApplicationController
end
def preview_markdown
- render_markdown_preview(params[:text], skip_project_check: true)
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ render json: {
+ body: view_context.markdown(result[:text], skip_project_check: true),
+ references: {
+ users: result[:users]
+ }
+ }
end
protected
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/controllers/users_controller.rb b/app/controllers/users_controller.rb
index a452bbba422..ca89ed221c6 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,7 +1,8 @@
class UsersController < ApplicationController
+ include RoutableActions
+
skip_before_action :authenticate_user!
before_action :user, except: [:exists]
- before_action :authorize_read_user!, only: [:show]
def show
respond_to do |format|
@@ -91,12 +92,8 @@ class UsersController < ApplicationController
private
- def authorize_read_user!
- render_404 unless can?(current_user, :read_user, user)
- end
-
def user
- @user ||= User.find_by_username!(params[:username])
+ @user ||= find_routable!(User, params[:username])
end
def contributed_projects
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 4cc42b88a2a..957ad875858 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -231,7 +231,7 @@ class IssuableFinder
when 'created-by-me', 'authored'
items.where(author_id: current_user.id)
when 'assigned-to-me'
- items.where(assignee_id: current_user.id)
+ items.assigned_to(current_user)
else
items
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 76715e5970d..b4c074bc69c 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder
IssuesFinder.not_restricted_by_confidentiality(current_user)
end
+ def by_assignee(items)
+ if assignee
+ items.assigned_to(assignee)
+ elsif no_assignee?
+ items.unassigned
+ elsif assignee_id? || assignee_username? # assignee not found
+ items.none
+ else
+ items
+ end
+ end
+
def self.not_restricted_by_confidentiality(user)
- return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
+ return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
return Issue.all if user.admin?
Issue.where('
- issues.confidential IS NULL
- OR issues.confidential IS FALSE
+ issues.confidential IS NOT TRUE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
- OR issues.assignee_id = :user_id
+ OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
OR issues.project_id IN(:project_ids)))',
user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
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/application_helper.rb b/app/helpers/application_helper.rb
index fff57472a4f..6d6bcbaf88a 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -180,16 +180,16 @@ module ApplicationHelper
element
end
- def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false)
- return if object.updated_at == object.created_at
+ def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
+ return if object.last_edited_at == object.created_at || object.last_edited_at.blank?
- content_tag :small, class: "edited-text" do
- output = content_tag(:span, "Edited ")
- output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class)
+ content_tag :small, class: 'edited-text' do
+ output = content_tag(:span, 'Edited ')
+ output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class)
- if include_author && object.updated_by && object.updated_by != object.author
- output << content_tag(:span, " by ")
- output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil)
+ if !exclude_author && object.last_edited_by
+ output << content_tag(:span, ' by ')
+ output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil)
end
output
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/boards_helper.rb b/app/helpers/boards_helper.rb
index f43827da446..e2df52e3833 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -9,6 +9,7 @@ module BoardsHelper
issue_link_base: namespace_project_issues_path(@project.namespace, @project),
root_path: root_path,
bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project),
+ default_avatar: image_path(default_avatar)
}
end
end
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index 2fcb7a59fc3..2eb2c6c7389 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -1,4 +1,16 @@
module BuildsHelper
+ def build_summary(build, skip: false)
+ if build.has_trace?
+ if skip
+ link_to "View job trace", pipeline_build_url(build.pipeline, build)
+ else
+ build.trace.html(last_lines: 10).html_safe
+ end
+ else
+ "No job trace"
+ end
+ end
+
def sidebar_build_class(build, current_build)
build_class = ''
build_class += ' active' if build.id === current_build.id
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 1182939f656..53962b84618 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -15,4 +15,36 @@ module FormHelper
end
end
end
+
+ def issue_dropdown_options(issuable, has_multiple_assignees = true)
+ options = {
+ toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
+ title: 'Select assignee',
+ filter: true,
+ dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
+ placeholder: 'Search users',
+ data: {
+ first_user: current_user&.username,
+ null_user: true,
+ current_user: true,
+ project_id: issuable.project.try(:id),
+ field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]",
+ default_label: 'Assignee',
+ 'max-select': 1,
+ 'dropdown-header': 'Assignee',
+ multi_select: true,
+ 'input-meta': 'name',
+ 'always-show-selectbox': true,
+ current_user_info: current_user.to_json(only: [:id, :name])
+ }
+ }
+
+ if has_multiple_assignees
+ options[:title] = 'Select assignee(s)'
+ options[:data][:'dropdown-header'] = 'Assignee(s)'
+ options[:data].delete(:'max-select')
+ end
+
+ options
+ end
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index e9b7cbbad6a..3769830de2a 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -122,6 +122,14 @@ module GitlabRoutingHelper
namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args)
end
+ def preview_markdown_path(project, *args)
+ if @snippet.is_a?(PersonalSnippet)
+ preview_markdown_snippet_path(@snippet)
+ else
+ preview_markdown_namespace_project_path(project.namespace, project, *args)
+ end
+ end
+
def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue)
toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity)
@@ -208,6 +216,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/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 0b13dbf5f8d..7656929efe7 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -63,6 +63,16 @@ module IssuablesHelper
end
end
+ def users_dropdown_label(selected_users)
+ if selected_users.length == 0
+ "Unassigned"
+ elsif selected_users.length == 1
+ selected_users[0].name
+ else
+ "#{selected_users[0].name} + #{selected_users.length - 1} more"
+ end
+ end
+
def user_dropdown_label(user_id, default_label)
return default_label if user_id.nil?
return "Unassigned" if user_id == "0"
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..52403640c05 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -60,24 +60,63 @@ 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?
namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor)
end
end
+
+ def notes_url
+ if @snippet.is_a?(PersonalSnippet)
+ snippet_notes_path(@snippet)
+ else
+ namespace_project_noteable_notes_path(
+ namespace_id: @project.namespace,
+ project_id: @project,
+ target_id: @noteable.id,
+ target_type: @noteable.class.name.underscore
+ )
+ end
+ end
+
+ def note_url(note)
+ if note.noteable.is_a?(PersonalSnippet)
+ snippet_note_path(note.noteable, note)
+ else
+ namespace_project_note_path(@project.namespace, @project, note)
+ end
+ end
+
+ def form_resources
+ if @snippet.is_a?(PersonalSnippet)
+ [@note]
+ else
+ [@project.namespace.becomes(Namespace), @project, @note]
+ end
+ end
+
+ def new_form_url
+ return nil unless @snippet.is_a?(PersonalSnippet)
+
+ snippet_notes_path(@snippet)
+ end
+
+ def can_create_note?
+ if @snippet.is_a?(PersonalSnippet)
+ can?(current_user, :comment_personal_snippet, @snippet)
+ else
+ can?(current_user, :create_note, @project)
+ end
+ end
end
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/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 1ea60e39386..d889d141101 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -1,6 +1,7 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
'commit' => 'icon_commit',
+ 'description' => 'icon_edit',
'merge' => 'icon_merge',
'merged' => 'icon_merged',
'opened' => 'icon_status_open',
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/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index d64e48f774b..0f847841295 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -11,10 +11,12 @@ module Emails
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
- def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id)
+ def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
setup_issue_mail(issue_id, recipient_id)
- @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
+ @previous_assignees = []
+ @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index cf042717c95..54f01f8637e 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -62,6 +62,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :sentry_enabled
+ validates :clientside_sentry_dsn,
+ presence: true,
+ if: :clientside_sentry_enabled
+
validates :akismet_api_key,
presence: true,
if: :akismet_enabled
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 1cdb8811cff..eaf0b713122 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -26,9 +26,10 @@ class Blob < SimpleDelegator
BlobViewer::Image,
BlobViewer::Sketch,
+ BlobViewer::Balsamiq,
BlobViewer::Video,
-
+
BlobViewer::PDF,
BlobViewer::BinarySTL,
@@ -75,19 +76,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 +117,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 +141,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 +180,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/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb
new file mode 100644
index 00000000000..f982521db99
--- /dev/null
+++ b/app/models/blob_viewer/balsamiq.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class Balsamiq < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'balsamiq'
+ self.extensions = %w(bmpr)
+ self.binary = true
+ self.switcher_icon = 'file-image-o'
+ self.switcher_title = 'preview'
+ end
+end
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/ci/group.rb b/app/models/ci/group.rb
new file mode 100644
index 00000000000..87898b086c6
--- /dev/null
+++ b/app/models/ci/group.rb
@@ -0,0 +1,40 @@
+module Ci
+ ##
+ # This domain model is a representation of a group of jobs that are related
+ # to each other, like `rspec 0 1`, `rspec 0 2`.
+ #
+ # It is not persisted in the database.
+ #
+ class Group
+ include StaticModel
+
+ attr_reader :stage, :name, :jobs
+
+ delegate :size, to: :jobs
+
+ def initialize(stage, name:, jobs:)
+ @stage = stage
+ @name = name
+ @jobs = jobs
+ end
+
+ def status
+ @status ||= commit_statuses.status
+ end
+
+ def detailed_status(current_user)
+ if jobs.one?
+ jobs.first.detailed_status(current_user)
+ else
+ Gitlab::Ci::Status::Group::Factory
+ .new(self, current_user).fabricate!
+ end
+ end
+
+ private
+
+ def commit_statuses
+ @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id))
+ end
+ end
+end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index e7d6b17d445..9bda3186c30 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -15,6 +15,14 @@ module Ci
@warnings = warnings
end
+ def groups
+ @groups ||= statuses.ordered.latest
+ .sort_by(&:sortable_name).group_by(&:group_name)
+ .map do |group_name, grouped_statuses|
+ Ci::Group.new(self, name: group_name, jobs: grouped_statuses)
+ end
+ end
+
def to_param
name
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/issuable.rb b/app/models/concerns/issuable.rb
index 26dbf4d9570..075ec575f9d 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -26,8 +26,8 @@ module Issuable
cache_markdown_field :description, issuable_state_filter_enabled: true
belongs_to :author, class_name: "User"
- belongs_to :assignee, class_name: "User"
belongs_to :updated_by, class_name: "User"
+ belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
def authors_loaded?
@@ -65,11 +65,8 @@ module Issuable
validates :title, presence: true, length: { maximum: 255 }
scope :authored, ->(user) { where(author_id: user) }
- scope :assigned_to, ->(u) { where(assignee_id: u.id)}
scope :recent, -> { reorder(id: :desc) }
scope :order_position_asc, -> { reorder(position: :asc) }
- scope :assigned, -> { where("assignee_id IS NOT NULL") }
- scope :unassigned, -> { where("assignee_id IS NULL") }
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
@@ -92,23 +89,14 @@ module Issuable
attr_mentionable :description
participant :author
- participant :assignee
participant :notes_with_associations
strip_attributes :title
acts_as_paranoid
- after_save :update_assignee_cache_counts, if: :assignee_id_changed?
after_save :record_metrics, unless: :imported?
- def update_assignee_cache_counts
- # make sure we flush the cache for both the old *and* new assignees(if they exist)
- previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
- previous_assignee&.update_cache_counts
- assignee&.update_cache_counts
- end
-
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
@@ -237,10 +225,6 @@ module Issuable
today? && created_at == updated_at
end
- def is_being_reassigned?
- assignee_id_changed?
- end
-
def open?
opened? || reopened?
end
@@ -269,7 +253,11 @@ module Issuable
# DEPRECATED
repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
}
- hook_data[:assignee] = assignee.hook_attrs if assignee
+ if self.is_a?(Issue)
+ hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any?
+ else
+ hook_data[:assignee] = assignee.hook_attrs if assignee
+ end
hook_data
end
@@ -331,11 +319,6 @@ module Issuable
false
end
- def assignee_or_author?(user)
- # We're comparing IDs here so we don't need to load any associations.
- author_id == user.id || assignee_id == user.id
- end
-
def record_metrics
metrics = self.metrics || create_metrics
metrics.record!
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index f449229864d..a3472af5c55 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -40,7 +40,7 @@ module Milestoneish
def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params)
- .execute.where(milestone_id: milestoneish_ids)
+ .execute.includes(:assignees).where(milestone_id: milestoneish_ids)
end
end
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/concerns/routable.rb b/app/models/concerns/routable.rb
index b28e05d0c28..c4463abdfe6 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -5,6 +5,7 @@ module Routable
included do
has_one :route, as: :source, autosave: true, dependent: :destroy
+ has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy
validates_associated :route
validates :route, presence: true
@@ -26,16 +27,31 @@ module Routable
# Klass.find_by_full_path('gitlab-org/gitlab-ce')
#
# Returns a single object, or nil.
- def find_by_full_path(path)
+ def find_by_full_path(path, follow_redirects: false)
# On MySQL we want to ensure the ORDER BY uses a case-sensitive match so
# any literal matches come first, for this we have to use "BINARY".
# Without this there's still no guarantee in what order MySQL will return
# rows.
+ #
+ # Why do we do this?
+ #
+ # Even though we have Rails validation on Route for unique paths
+ # (case-insensitive), there are old projects in our DB (and possibly
+ # clients' DBs) that have the same path with different cases.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/18603. Also note that
+ # our unique index is case-sensitive in Postgres.
binary = Gitlab::Database.mysql? ? 'BINARY' : ''
-
order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)"
-
- where_full_path_in([path]).reorder(order_sql).take
+ found = where_full_path_in([path]).reorder(order_sql).take
+ return found if found
+
+ if follow_redirects
+ if Gitlab::Database.postgresql?
+ joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path)
+ else
+ joins(:redirect_routes).find_by(redirect_routes: { path: path })
+ end
+ end
end
# Builds a relation to find multiple objects by their full paths.
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/event.rb b/app/models/event.rb
index b780c1faf81..e6fad46077a 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -30,6 +30,7 @@ class Event < ActiveRecord::Base
# Callbacks
after_create :reset_project_activity
+ after_create :set_last_repository_updated_at, if: :push?
# Scopes
scope :recent, -> { reorder(id: :desc) }
@@ -357,4 +358,9 @@ class Event < ActiveRecord::Base
def recent_update?
project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago
end
+
+ def set_last_repository_updated_at
+ Project.unscoped.where(id: project_id).
+ update_all(last_repository_updated_at: created_at)
+ end
end
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index 0afbca2cb32..538615130a7 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -36,7 +36,7 @@ class GlobalMilestone
closed = count_by_state(milestones_by_state_and_title, 'closed')
all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
- {
+ {
opened: opened,
closed: closed,
all: all
@@ -86,7 +86,7 @@ class GlobalMilestone
end
def issues
- @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels)
+ @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
end
def merge_requests
@@ -94,7 +94,7 @@ class GlobalMilestone
end
def participants
- @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq
+ @participants ||= milestones.map(&:participants).flatten.uniq
end
def labels
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 305fc01f041..27e3ed9bc7f 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
+ has_many :issue_assignees
+ has_many :assignees, class_name: "User", through: :issue_assignees
+
validates :project, presence: true
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
+ scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
+ scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
+ scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
+
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
@@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
- scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
+ scope :include_associations, -> { includes(:labels, project: :namespace) }
after_save :expire_etag_cache
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
+ participant :assignees
+
state_machine :state, initial: :opened do
event :close do
transition [:reopened, :opened] => :closed
@@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base
end
def hook_attrs
+ assignee_ids = self.assignee_ids
+
attrs = {
total_time_spent: total_time_spent,
human_total_time_spent: human_total_time_spent,
- human_time_estimate: human_time_estimate
+ human_time_estimate: human_time_estimate,
+ assignee_ids: assignee_ids,
+ assignee_id: assignee_ids.first # This key is deprecated
}
attributes.merge!(attrs)
@@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base
"id DESC")
end
+ # Returns a Hash of attributes to be used for Twitter card metadata
+ def card_attributes
+ {
+ 'Author' => author.try(:name),
+ 'Assignee' => assignee_list
+ }
+ end
+
+ def assignee_or_author?(user)
+ author_id == user.id || assignees.exists?(user.id)
+ end
+
+ def assignee_list
+ assignees.map(&:name).to_sentence
+ end
+
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -143,6 +172,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
@@ -240,7 +277,7 @@ class Issue < ActiveRecord::Base
true
elsif confidential?
author == user ||
- assignee == user ||
+ assignees.include?(user) ||
project.team.member?(user, Gitlab::Access::REPORTER)
else
project.public? ||
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
new file mode 100644
index 00000000000..0663d3aaef8
--- /dev/null
+++ b/app/models/issue_assignee.rb
@@ -0,0 +1,13 @@
+class IssueAssignee < ActiveRecord::Base
+ extend Gitlab::CurrentSettings
+
+ belongs_to :issue
+ belongs_to :assignee, class_name: "User", foreign_key: :user_id
+
+ after_create :update_assignee_cache_counts
+ after_destroy :update_assignee_cache_counts
+
+ def update_assignee_cache_counts
+ assignee&.update_cache_counts
+ end
+end
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 365fa4f1e70..35231bab12e 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
+ belongs_to :assignee, class_name: "User"
+
serialize :merge_params, Hash
after_create :ensure_merge_request_diff, unless: :importing?
@@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
+ scope :assigned, -> { where("assignee_id IS NOT NULL") }
+ scope :unassigned, -> { where("assignee_id IS NULL") }
+ scope :assigned_to, ->(u) { where(assignee_id: u.id)}
+
+ participant :assignee
after_save :keep_around_commit
+ after_save :update_assignee_cache_counts, if: :assignee_id_changed?
def self.reference_prefix
'!'
@@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}"
end
+ def update_assignee_cache_counts
+ # make sure we flush the cache for both the old *and* new assignees(if they exist)
+ previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
+ previous_assignee&.update_cache_counts
+ assignee&.update_cache_counts
+ end
+
+ # Returns a Hash of attributes to be used for Twitter card metadata
+ def card_attributes
+ {
+ 'Author' => author.try(:name),
+ 'Assignee' => assignee.try(:name)
+ }
+ end
+
+ # This method is needed for compatibility with issues to not mess view and other code
+ def assignees
+ Array(assignee)
+ end
+
+ def assignee_or_author?(user)
+ author_id == user.id || assignee_id == user.id
+ end
+
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -374,12 +406,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/milestone.rb b/app/models/milestone.rb
index 652b1551928..c06bfe0ccdd 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
- has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
has_many :events, as: :target, dependent: :destroy
scope :active, -> { with_state(:active) }
@@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base
end
end
+ def participants
+ User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
+ end
+
def self.sort(method)
case method.to_s
when 'due_date_asc'
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..46d0a4f159f 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -18,6 +18,11 @@ class Note < ActiveRecord::Base
cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
+ # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102
+ alias_attribute :last_edited_at, :updated_at
+ alias_attribute :last_edited_by, :updated_by
+
# Attribute containing rendered and redacted Markdown as generated by
# Banzai::ObjectRenderer.
attr_accessor :redacted_note_html
@@ -38,6 +43,7 @@ class Note < ActiveRecord::Base
belongs_to :noteable, polymorphic: true, touch: true
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
+ belongs_to :last_edited_by, class_name: 'User'
has_many :todos, dependent: :destroy
has_many :events, as: :target, dependent: :destroy
@@ -115,11 +121,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 +155,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 9d64e5d406d..edbca3b537b 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -53,6 +53,11 @@ class Project < ActiveRecord::Base
update_column(:last_activity_at, self.created_at)
end
+ after_create :set_last_repository_updated_at
+ def set_last_repository_updated_at
+ update_column(:last_repository_updated_at, self.created_at)
+ end
+
after_destroy :remove_pages
# update visibility_level of forks
@@ -196,13 +201,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 +1276,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
@@ -1391,4 +1400,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/project_wiki.rb b/app/models/project_wiki.rb
index 70eef359cdd..189c106b70b 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -183,6 +183,6 @@ class ProjectWiki
end
def update_project_activity
- @project.touch(:last_activity_at)
+ @project.touch(:last_activity_at, :last_repository_updated_at)
end
end
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
new file mode 100644
index 00000000000..99812bcde53
--- /dev/null
+++ b/app/models/redirect_route.rb
@@ -0,0 +1,12 @@
+class RedirectRoute < ActiveRecord::Base
+ belongs_to :source, polymorphic: true
+
+ validates :source, presence: true
+
+ validates :path,
+ length: { within: 1..255 },
+ presence: true,
+ uniqueness: { case_sensitive: false }
+
+ scope :matching_path_and_descendants, -> (path) { where('redirect_routes.path = ? OR redirect_routes.path LIKE ?', path, "#{sanitize_sql_like(path)}/%") }
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index ba34d570dbd..0c797dd5814 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -789,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
@@ -836,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
@@ -859,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
@@ -883,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
@@ -900,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
@@ -1142,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/route.rb b/app/models/route.rb
index 4b3efab5c3c..12a7fa3d01b 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -8,29 +8,58 @@ class Route < ActiveRecord::Base
presence: true,
uniqueness: { case_sensitive: false }
+ after_create :delete_conflicting_redirects
+ after_update :delete_conflicting_redirects, if: :path_changed?
+ after_update :create_redirect_for_old_path
after_update :rename_descendants
scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") }
def rename_descendants
- if path_changed? || name_changed?
- descendants = self.class.inside_path(path_was)
+ return unless path_changed? || name_changed?
- descendants.each do |route|
- attributes = {}
+ descendant_routes = self.class.inside_path(path_was)
- if path_changed? && route.path.present?
- attributes[:path] = route.path.sub(path_was, path)
- end
+ descendant_routes.each do |route|
+ attributes = {}
- if name_changed? && name_was.present? && route.name.present?
- attributes[:name] = route.name.sub(name_was, name)
- end
+ if path_changed? && route.path.present?
+ attributes[:path] = route.path.sub(path_was, path)
+ end
- # Note that update_columns skips validation and callbacks.
- # We need this to avoid recursive call of rename_descendants method
- route.update_columns(attributes) unless attributes.empty?
+ if name_changed? && name_was.present? && route.name.present?
+ attributes[:name] = route.name.sub(name_was, name)
+ end
+
+ if attributes.present?
+ old_path = route.path
+
+ # Callbacks must be run manually
+ route.update_columns(attributes)
+
+ # We are not calling route.delete_conflicting_redirects here, in hopes
+ # of avoiding deadlocks. The parent (self, in this method) already
+ # called it, which deletes conflicts for all descendants.
+ route.create_redirect(old_path) if attributes[:path]
end
end
end
+
+ def delete_conflicting_redirects
+ conflicting_redirects.delete_all
+ end
+
+ def conflicting_redirects
+ RedirectRoute.matching_path_and_descendants(path)
+ end
+
+ def create_redirect(path)
+ RedirectRoute.create(source: source, path: path)
+ end
+
+ private
+
+ def create_redirect_for_old_path
+ create_redirect(path_was) if path_changed?
+ end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index d8860718cb5..abfbefdf9a0 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -12,6 +12,11 @@ class Snippet < ActiveRecord::Base
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :content
+ # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102
+ alias_attribute :last_edited_at, :updated_at
+ alias_attribute :last_edited_by, :updated_by
+
# If file_name changes, it invalidates content
alias_method :default_content_html_invalidator, :content_html_invalidated?
def content_html_invalidated?
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/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 1e6fc837a75..b44f4fe000c 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,6 +1,6 @@
class SystemNoteMetadata < ActiveRecord::Base
ICON_TYPES = %w[
- commit merge confidential visible label assignee cross_reference
+ commit description merge confidential visible label assignee cross_reference
title time_tracking branch milestone discussion task moved opened closed merged
].freeze
diff --git a/app/models/user.rb b/app/models/user.rb
index bd9c9f99663..accaa91b805 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -23,6 +23,7 @@ class User < ActiveRecord::Base
default_value_for :hide_no_password, false
default_value_for :project_view, :files
default_value_for :notified_of_own_activity, false
+ default_value_for :preferred_language, I18n.default_locale
attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base,
@@ -99,6 +100,10 @@ class User < ActiveRecord::Base
has_many :award_emoji, dependent: :destroy
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
+ has_many :issue_assignees
+ has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
+ has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
+
# Issues that a user owns are expected to be moved to the "ghost" user before
# the user is destroyed. If the user owns any issues during deletion, this
# should be treated as an exceptional condition.
@@ -118,7 +123,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 }
@@ -332,6 +337,11 @@ class User < ActiveRecord::Base
find_by(id: Key.unscoped.select(:user_id).where(id: key_id))
end
+ def find_by_full_path(path, follow_redirects: false)
+ namespace = Namespace.find_by_full_path(path, follow_redirects: follow_redirects)
+ namespace&.owner
+ end
+
def reference_prefix
'@'
end
@@ -354,6 +364,10 @@ class User < ActiveRecord::Base
end
end
+ def full_path
+ username
+ end
+
def self.internal_attributes
[:ghost]
end
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/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
index 69bf693de8d..564612202b5 100644
--- a/app/serializers/analytics_stage_entity.rb
+++ b/app/serializers/analytics_stage_entity.rb
@@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity
include EntityDateHelper
expose :title
+ expose :name
expose :legend
expose :description
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
index 91803ec07f5..9c37afd53e1 100644
--- a/app/serializers/analytics_summary_entity.rb
+++ b/app/serializers/analytics_summary_entity.rb
@@ -1,7 +1,4 @@
class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true
-
- expose :title do |object|
- object.title.pluralize(object.value)
- end
+ expose :title
end
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/issuable_entity.rb b/app/serializers/issuable_entity.rb
index 29aecb50849..65b204d4dd2 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -1,7 +1,6 @@
class IssuableEntity < Grape::Entity
expose :id
expose :iid
- expose :assignee_id
expose :author_id
expose :description
expose :lock_version
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 6429159ebe1..bc4f68710b2 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -1,6 +1,7 @@
class IssueEntity < IssuableEntity
expose :branch_name
expose :confidential
+ expose :assignees, using: API::Entities::UserBasic
expose :due_date
expose :moved_to_id
expose :project_id
diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb
new file mode 100644
index 00000000000..a4d3737429c
--- /dev/null
+++ b/app/serializers/job_group_entity.rb
@@ -0,0 +1,16 @@
+class JobGroupEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :name
+ expose :size
+ expose :detailed_status, as: :status, with: StatusEntity
+ expose :jobs, with: BuildEntity
+
+ private
+
+ alias_method :group, :object
+
+ def detailed_status
+ group.detailed_status(request.user)
+ end
+end
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
index 304fd9de08f..ad565654342 100644
--- a/app/serializers/label_entity.rb
+++ b/app/serializers/label_entity.rb
@@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity
expose :group_id
expose :project_id
expose :template
+ expose :text_color
expose :created_at
expose :updated_at
end
diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb
new file mode 100644
index 00000000000..ad6ba8c46c9
--- /dev/null
+++ b/app/serializers/label_serializer.rb
@@ -0,0 +1,7 @@
+class LabelSerializer < BaseSerializer
+ entity LabelEntity
+
+ def represent_appearance(resource)
+ represent(resource, { only: [:id, :title, :color, :text_color] })
+ 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/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
index 5f80ab397a9..453ba52b892 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_entity.rb
@@ -1,4 +1,5 @@
class MergeRequestEntity < IssuableEntity
+ expose :assignee_id
expose :in_progress_merge_commit_sha
expose :locked_at
expose :merge_commit_sha
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/stage_entity.rb b/app/serializers/stage_entity.rb
index 7a047bdc712..97ced8730ed 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -7,9 +7,11 @@ class StageEntity < Grape::Entity
"#{stage.name}: #{detailed_status.label}"
end
- expose :detailed_status,
- as: :status,
- with: StatusEntity
+ expose :groups,
+ if: -> (_, opts) { opts[:grouped] },
+ with: JobGroupEntity
+
+ expose :detailed_status, as: :status, with: StatusEntity
expose :path do |stage|
namespace_project_pipeline_path(
diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb
index 188c3747f18..3e40ecf1c1c 100644
--- a/app/serializers/status_entity.rb
+++ b/app/serializers/status_entity.rb
@@ -12,4 +12,11 @@ class StatusEntity < Grape::Entity
ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico"))
end
+
+ expose :action, if: -> (status, _) { status.has_action? } do
+ expose :action_icon, as: :icon
+ expose :action_title, as: :title
+ expose :action_path, as: :path
+ expose :action_method, as: :method
+ end
end
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index 60891cbb255..40ff9b8b867 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -7,10 +7,14 @@ module Issuable
ids = params.delete(:issuable_ids).split(",")
items = model_class.where(id: ids)
- %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key|
+ %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key|
params.delete(key) unless params[key].present?
end
+ if params[:assignee_ids] == [IssuableFinder::NONE.to_s]
+ params[:assignee_ids] = []
+ end
+
items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable)
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index b071a398481..c1e532b504a 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -1,11 +1,6 @@
class IssuableBaseService < BaseService
private
- def create_assignee_note(issuable)
- SystemNoteService.change_assignee(
- issuable, issuable.project, current_user, issuable.assignee)
- end
-
def create_milestone_note(issuable)
SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone)
@@ -24,6 +19,10 @@ class IssuableBaseService < BaseService
issuable, issuable.project, current_user, old_title)
end
+ def create_description_change_note(issuable)
+ SystemNoteService.change_description(issuable, issuable.project, current_user)
+ end
+
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
@@ -53,6 +52,7 @@ class IssuableBaseService < BaseService
params.delete(:add_label_ids)
params.delete(:remove_label_ids)
params.delete(:label_ids)
+ params.delete(:assignee_ids)
params.delete(:assignee_id)
params.delete(:due_date)
end
@@ -77,7 +77,7 @@ class IssuableBaseService < BaseService
def assignee_can_read?(issuable, assignee_id)
new_assignee = User.find_by_id(assignee_id)
- return false unless new_assignee.present?
+ return false unless new_assignee
ability_name = :"read_#{issuable.to_ability_name}"
resource = issuable.persisted? ? issuable : project
@@ -207,6 +207,7 @@ class IssuableBaseService < BaseService
filter_params(issuable)
old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a
+ old_assignees = issuable.assignees.to_a
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
@@ -214,6 +215,10 @@ class IssuableBaseService < BaseService
if issuable.changed? || params.present?
issuable.assign_attributes(params.merge(updated_by: current_user))
+ if has_title_or_description_changed?(issuable)
+ issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user)
+ end
+
before_update(issuable)
if issuable.with_transaction_returning_status { issuable.save }
@@ -222,7 +227,13 @@ class IssuableBaseService < BaseService
handle_common_system_notes(issuable, old_labels: old_labels)
end
- handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users)
+ handle_changes(
+ issuable,
+ old_labels: old_labels,
+ old_mentioned_users: old_mentioned_users,
+ old_assignees: old_assignees
+ )
+
after_update(issuable)
issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update')
@@ -236,6 +247,10 @@ class IssuableBaseService < BaseService
old_label_ids.sort != new_label_ids.sort
end
+ def has_title_or_description_changed?(issuable)
+ issuable.title_changed? || issuable.description_changed?
+ end
+
def change_state(issuable)
case params.delete(:state_event)
when 'reopen'
@@ -272,7 +287,7 @@ class IssuableBaseService < BaseService
end
end
- def has_changes?(issuable, old_labels: [])
+ def has_changes?(issuable, old_labels: [], old_assignees: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr|
@@ -281,7 +296,9 @@ class IssuableBaseService < BaseService
labels_changed = issuable.labels != old_labels
- attrs_changed || labels_changed
+ assignees_changed = issuable.assignees != old_assignees
+
+ attrs_changed || labels_changed || assignees_changed
end
def handle_common_system_notes(issuable, old_labels: [])
@@ -289,6 +306,10 @@ class IssuableBaseService < BaseService
create_title_change_note(issuable, issuable.previous_changes['title'].first)
end
+ if issuable.previous_changes.include?('description')
+ create_description_change_note(issuable)
+ end
+
if issuable.previous_changes.include?('description') && issuable.tasks?
create_task_status_note(issuable)
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index ee1b40db718..34199eb5d13 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -9,11 +9,33 @@ module Issues
private
+ def create_assignee_note(issue, old_assignees)
+ SystemNoteService.change_issue_assignees(
+ issue, issue.project, current_user, old_assignees)
+ end
+
def execute_hooks(issue, action = 'open')
issue_data = hook_data(issue, action)
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_services(issue_data, hooks_scope)
end
+
+ def filter_assignee(issuable)
+ return if params[:assignee_ids].blank?
+
+ # The number of assignees is limited by one for GitLab CE
+ params[:assignee_ids] = params[:assignee_ids][0, 1]
+
+ assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
+
+ if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
+ params[:assignee_ids] = []
+ elsif assignee_ids.any?
+ params[:assignee_ids] = assignee_ids
+ else
+ params.delete(:assignee_ids)
+ end
+ end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index b7fe5cb168b..cd9f9a4a16e 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -12,8 +12,12 @@ module Issues
spam_check(issue, current_user)
end
- def handle_changes(issue, old_labels: [], old_mentioned_users: [])
- if has_changes?(issue, old_labels: old_labels)
+ def handle_changes(issue, options)
+ old_labels = options[:old_labels] || []
+ old_mentioned_users = options[:old_mentioned_users] || []
+ old_assignees = options[:old_assignees] || []
+
+ if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(issue, current_user)
end
@@ -26,9 +30,9 @@ module Issues
create_milestone_note(issue)
end
- if issue.previous_changes.include?('assignee_id')
- create_assignee_note(issue)
- notification_service.reassigned_issue(issue, current_user)
+ if issue.assignees != old_assignees
+ create_assignee_note(issue, old_assignees)
+ notification_service.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_issue(issue, current_user)
end
diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb
index 1711be7211c..a85b9465c84 100644
--- a/app/services/members/authorized_destroy_service.rb
+++ b/app/services/members/authorized_destroy_service.rb
@@ -26,15 +26,22 @@ module Members
def unassign_issues_and_merge_requests(member)
if member.is_a?(GroupMember)
- IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
- execute.
- update_all(assignee_id: nil)
+ issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
+ execute.pluck(:id)
+
+ IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id)
+
MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
execute.
update_all(assignee_id: nil)
else
project = member.source
- project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil)
+
+ IssueAssignee.destroy_all(
+ user_id: member.user_id,
+ issue_id: project.issues.opened.assigned_to(member.user).select(:id)
+ )
+
project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
member.user.update_cache_counts
end
diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb
index 066efa1acc3..8c6c4841020 100644
--- a/app/services/merge_requests/assign_issues_service.rb
+++ b/app/services/merge_requests/assign_issues_service.rb
@@ -4,7 +4,7 @@ module MergeRequests
@assignable_issues ||= begin
if current_user == merge_request.author
closes_issues.select do |issue|
- !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue)
+ !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue)
end
else
[]
@@ -14,7 +14,7 @@ module MergeRequests
def execute
assignable_issues.each do |issue|
- Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue)
+ Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue)
end
{
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 582d5c47b66..3542a41ac83 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -38,6 +38,11 @@ module MergeRequests
private
+ def create_assignee_note(merge_request)
+ SystemNoteService.change_assignee(
+ merge_request, merge_request.project, current_user, merge_request.assignee)
+ end
+
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
MergeRequest
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/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index ab7fcf3b6e2..5c843a258fb 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -21,7 +21,10 @@ module MergeRequests
update(merge_request)
end
- def handle_changes(merge_request, old_labels: [], old_mentioned_users: [])
+ def handle_changes(merge_request, options)
+ old_labels = options[:old_labels] || []
+ old_mentioned_users = options[:old_mentioned_users] || []
+
if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index ea7cacc956c..abf25bb778b 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -3,8 +3,8 @@ module Notes
def execute
in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id)
- if project && in_reply_to_discussion_id.present?
- discussion = project.notes.find_discussion(in_reply_to_discussion_id)
+ if in_reply_to_discussion_id.present?
+ discussion = find_discussion(in_reply_to_discussion_id)
unless discussion
note = Note.new
@@ -21,5 +21,19 @@ module Notes
note
end
+
+ def find_discussion(discussion_id)
+ if project
+ project.notes.find_discussion(discussion_id)
+ else
+ # only PersonalSnippets can have discussions without project association
+ discussion = Note.find_discussion(discussion_id)
+ noteable = discussion.noteable
+
+ return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
+
+ discussion
+ end
+ end
end
end
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 8bb995158de..988bd0a7cdb 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -19,9 +19,14 @@ class NotificationRecipientService
# Re-assign is considered as a mention of the new assignee so we add the
# new assignee to the list of recipients after we rejected users with
# the "on mention" notification level
- if [:reassign_merge_request, :reassign_issue].include?(custom_action)
+ case custom_action
+ when :reassign_merge_request
recipients << previous_assignee if previous_assignee
recipients << target.assignee
+ when :reassign_issue
+ previous_assignees = Array(previous_assignee)
+ recipients.concat(previous_assignees)
+ recipients.concat(target.assignees)
end
recipients = reject_muted_users(recipients)
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 6b186263bd1..c65c66d7150 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -66,8 +66,25 @@ class NotificationService
# * issue new assignee if their notification level is not Disabled
# * users with custom level checked with "reassign issue"
#
- def reassigned_issue(issue, current_user)
- reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email)
+ def reassigned_issue(issue, current_user, previous_assignees = [])
+ recipients = NotificationRecipientService.new(issue.project).build_recipients(
+ issue,
+ current_user,
+ action: "reassign",
+ previous_assignee: previous_assignees
+ )
+
+ previous_assignee_ids = previous_assignees.map(&:id)
+
+ recipients.each do |recipient|
+ mailer.send(
+ :reassigned_issue_email,
+ recipient.id,
+ issue.id,
+ previous_assignee_ids,
+ current_user.id
+ ).deliver_later
+ end
end
# When we add labels to an issue we should send an email to:
@@ -367,10 +384,10 @@ class NotificationService
end
def previous_record(object, attribute)
- if object && attribute
- if object.previous_changes.include?(attribute)
- object.previous_changes[attribute].first
- end
+ return unless object && attribute
+
+ if object.previous_changes.include?(attribute)
+ object.previous_changes[attribute].first
end
end
end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
new file mode 100644
index 00000000000..10d45bbf73c
--- /dev/null
+++ b/app/services/preview_markdown_service.rb
@@ -0,0 +1,45 @@
+class PreviewMarkdownService < BaseService
+ def execute
+ text, commands = explain_slash_commands(params[:text])
+ users = find_user_references(text)
+
+ success(
+ text: text,
+ users: users,
+ commands: commands.join(' ')
+ )
+ end
+
+ private
+
+ def explain_slash_commands(text)
+ return text, [] unless %w(Issue MergeRequest).include?(commands_target_type)
+
+ slash_commands_service = SlashCommands::InterpretService.new(project, current_user)
+ slash_commands_service.explain(text, find_commands_target)
+ end
+
+ def find_user_references(text)
+ extractor = Gitlab::ReferenceExtractor.new(project, current_user)
+ extractor.analyze(text, author: current_user)
+ extractor.users.map(&:username)
+ end
+
+ def find_commands_target
+ if commands_target_id.present?
+ finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder
+ finder.new(current_user, project_id: project.id).find(commands_target_id)
+ else
+ collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests
+ collection.build
+ end
+ end
+
+ def commands_target_type
+ params[:slash_commands_target_type]
+ end
+
+ def commands_target_id
+ params[:slash_commands_target_id]
+ 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/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
new file mode 100644
index 00000000000..a8ef2108492
--- /dev/null
+++ b/app/services/projects/propagate_service_template.rb
@@ -0,0 +1,103 @@
+module Projects
+ class PropagateServiceTemplate
+ BATCH_SIZE = 100
+
+ def self.propagate(*args)
+ new(*args).propagate
+ end
+
+ def initialize(template)
+ @template = template
+ end
+
+ def propagate
+ return unless @template.active?
+
+ Rails.logger.info("Propagating services for template #{@template.id}")
+
+ propagate_projects_with_template
+ end
+
+ private
+
+ def propagate_projects_with_template
+ loop do
+ batch = project_ids_batch
+
+ bulk_create_from_template(batch) unless batch.empty?
+
+ break if batch.size < BATCH_SIZE
+ end
+ end
+
+ def bulk_create_from_template(batch)
+ service_list = batch.map do |project_id|
+ service_hash.values << project_id
+ end
+
+ Project.transaction do
+ bulk_insert_services(service_hash.keys << 'project_id', service_list)
+ run_callbacks(batch)
+ end
+ end
+
+ def project_ids_batch
+ Project.connection.select_values(
+ <<-SQL
+ SELECT id
+ FROM projects
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM services
+ WHERE services.project_id = projects.id
+ AND services.type = '#{@template.type}'
+ )
+ AND projects.pending_delete = false
+ AND projects.archived = false
+ LIMIT #{BATCH_SIZE}
+ SQL
+ )
+ end
+
+ def bulk_insert_services(columns, values_array)
+ ActiveRecord::Base.connection.execute(
+ <<-SQL.strip_heredoc
+ INSERT INTO services (#{columns.join(', ')})
+ VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
+ SQL
+ )
+ end
+
+ def service_hash
+ @service_hash ||=
+ begin
+ template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id')
+
+ template_hash.each_with_object({}) do |(key, value), service_hash|
+ value = value.is_a?(Hash) ? value.to_json : value
+
+ service_hash[ActiveRecord::Base.connection.quote_column_name(key)] =
+ ActiveRecord::Base.sanitize(value)
+ end
+ end
+ end
+
+ def run_callbacks(batch)
+ if active_external_issue_tracker?
+ Project.where(id: batch).update_all(has_external_issue_tracker: true)
+ end
+
+ if active_external_wiki?
+ Project.where(id: batch).update_all(has_external_wiki: true)
+ end
+ end
+
+ def active_external_issue_tracker?
+ @template.issue_tracker? && !@template.default
+ end
+
+ def active_external_wiki?
+ @template.type == 'ExternalWikiService'
+ end
+ end
+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/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index 6aeebc26685..a7e13648b54 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -2,7 +2,7 @@ module SlashCommands
class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl
- attr_reader :issuable, :options
+ attr_reader :issuable
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
@@ -12,23 +12,21 @@ module SlashCommands
@issuable = issuable
@updates = {}
- opts = {
- issuable: issuable,
- current_user: current_user,
- project: project,
- params: params
- }
-
- content, commands = extractor.extract_commands(content, opts)
+ content, commands = extractor.extract_commands(content, context)
+ extract_updates(commands, context)
+ [content, @updates]
+ end
- commands.each do |name, arg|
- definition = self.class.command_definitions_by_name[name.to_sym]
- next unless definition
+ # Takes a text and interprets the commands that are extracted from it.
+ # Returns the content without commands, and array of changes explained.
+ def explain(content, issuable)
+ return [content, []] unless current_user.can?(:use_slash_commands)
- definition.execute(self, opts, arg)
- end
+ @issuable = issuable
- [content, @updates]
+ content, commands = extractor.extract_commands(content, context)
+ commands = explain_commands(commands, context)
+ [content, commands]
end
private
@@ -40,6 +38,9 @@ module SlashCommands
desc do
"Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
+ explanation do
+ "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
condition do
issuable.persisted? &&
issuable.open? &&
@@ -52,6 +53,9 @@ module SlashCommands
desc do
"Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
+ explanation do
+ "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
condition do
issuable.persisted? &&
issuable.closed? &&
@@ -62,6 +66,7 @@ module SlashCommands
end
desc 'Merge (when the pipeline succeeds)'
+ explanation 'Merges this merge request when the pipeline succeeds.'
condition do
last_diff_sha = params && params[:merge_request_diff_head_sha]
issuable.is_a?(MergeRequest) &&
@@ -73,6 +78,9 @@ module SlashCommands
end
desc 'Change title'
+ explanation do |title_param|
+ "Changes the title to \"#{title_param}\"."
+ end
params '<New title>'
condition do
issuable.persisted? &&
@@ -83,41 +91,70 @@ module SlashCommands
end
desc 'Assign'
+ explanation do |users|
+ "Assigns #{users.map(&:to_reference).to_sentence}." if users.any?
+ end
params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
- command :assign do |assignee_param|
- user = extract_references(assignee_param, :user).first
- user ||= User.find_by(username: assignee_param)
+ parse_params do |assignee_param|
+ users = extract_references(assignee_param, :user)
+
+ if users.empty?
+ users = User.where(username: assignee_param.split(' ').map(&:strip))
+ end
+
+ users
+ end
+ command :assign do |users|
+ next if users.empty?
- @updates[:assignee_id] = user.id if user
+ if issuable.is_a?(Issue)
+ @updates[:assignee_ids] = users.map(&:id)
+ else
+ @updates[:assignee_id] = users.last.id
+ end
end
desc 'Remove assignee'
+ explanation do
+ "Removes assignee #{issuable.assignees.first.to_reference}."
+ end
condition do
issuable.persisted? &&
- issuable.assignee_id? &&
+ issuable.assignees.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :unassign do
- @updates[:assignee_id] = nil
+ if issuable.is_a?(Issue)
+ @updates[:assignee_ids] = []
+ else
+ @updates[:assignee_id] = nil
+ end
end
desc 'Set milestone'
+ explanation do |milestone|
+ "Sets the milestone to #{milestone.to_reference}." if milestone
+ end
params '%"milestone"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.milestones.active.any?
end
- command :milestone do |milestone_param|
- milestone = extract_references(milestone_param, :milestone).first
- milestone ||= project.milestones.find_by(title: milestone_param.strip)
-
+ parse_params do |milestone_param|
+ extract_references(milestone_param, :milestone).first ||
+ project.milestones.find_by(title: milestone_param.strip)
+ end
+ command :milestone do |milestone|
@updates[:milestone_id] = milestone.id if milestone
end
desc 'Remove milestone'
+ explanation do
+ "Removes #{issuable.milestone.to_reference(format: :name)} milestone."
+ end
condition do
issuable.persisted? &&
issuable.milestone_id? &&
@@ -128,6 +165,11 @@ module SlashCommands
end
desc 'Add label(s)'
+ explanation do |labels_param|
+ labels = find_label_references(labels_param)
+
+ "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
+ end
params '~label1 ~"label 2"'
condition do
available_labels = LabelsFinder.new(current_user, project_id: project.id).execute
@@ -147,6 +189,14 @@ module SlashCommands
end
desc 'Remove all or specific label(s)'
+ explanation do |labels_param = nil|
+ if labels_param.present?
+ labels = find_label_references(labels_param)
+ "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
+ else
+ 'Removes all labels.'
+ end
+ end
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
@@ -169,6 +219,10 @@ module SlashCommands
end
desc 'Replace all label(s)'
+ explanation do |labels_param|
+ labels = find_label_references(labels_param)
+ "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
+ end
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
@@ -187,6 +241,7 @@ module SlashCommands
end
desc 'Add a todo'
+ explanation 'Adds a todo.'
condition do
issuable.persisted? &&
!TodoService.new.todo_exist?(issuable, current_user)
@@ -196,6 +251,7 @@ module SlashCommands
end
desc 'Mark todo as done'
+ explanation 'Marks todo as done.'
condition do
issuable.persisted? &&
TodoService.new.todo_exist?(issuable, current_user)
@@ -205,6 +261,9 @@ module SlashCommands
end
desc 'Subscribe'
+ explanation do
+ "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
condition do
issuable.persisted? &&
!issuable.subscribed?(current_user, project)
@@ -214,6 +273,9 @@ module SlashCommands
end
desc 'Unsubscribe'
+ explanation do
+ "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
condition do
issuable.persisted? &&
issuable.subscribed?(current_user, project)
@@ -223,18 +285,23 @@ module SlashCommands
end
desc 'Set due date'
+ explanation do |due_date|
+ "Sets the due date to #{due_date.to_s(:medium)}." if due_date
+ end
params '<in 2 days | this Friday | December 31st>'
condition do
issuable.respond_to?(:due_date) &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
- command :due do |due_date_param|
- due_date = Chronic.parse(due_date_param).try(:to_date)
-
+ parse_params do |due_date_param|
+ Chronic.parse(due_date_param).try(:to_date)
+ end
+ command :due do |due_date|
@updates[:due_date] = due_date if due_date
end
desc 'Remove due date'
+ explanation 'Removes the due date.'
condition do
issuable.persisted? &&
issuable.respond_to?(:due_date) &&
@@ -245,8 +312,11 @@ module SlashCommands
@updates[:due_date] = nil
end
- desc do
- "Toggle the Work In Progress status"
+ desc 'Toggle the Work In Progress status'
+ explanation do
+ verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks'
+ noun = issuable.to_ability_name.humanize(capitalize: false)
+ "#{verb} this #{noun} as Work In Progress."
end
condition do
issuable.persisted? &&
@@ -257,45 +327,72 @@ module SlashCommands
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
end
- desc 'Toggle emoji reward'
+ desc 'Toggle emoji award'
+ explanation do |name|
+ "Toggles :#{name}: emoji award." if name
+ end
params ':emoji:'
condition do
issuable.persisted?
end
- command :award do |emoji|
- name = award_emoji_name(emoji)
+ parse_params do |emoji_param|
+ match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern)
+ match[1] if match
+ end
+ command :award do |name|
if name && issuable.user_can_award?(current_user, name)
@updates[:emoji_award] = name
end
end
desc 'Set time estimate'
+ explanation do |time_estimate|
+ time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate)
+
+ "Sets time estimate to #{time_estimate}." if time_estimate
+ end
params '<1w 3d 2h 14m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
- command :estimate do |raw_duration|
- time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
-
+ parse_params do |raw_duration|
+ Gitlab::TimeTrackingFormatter.parse(raw_duration)
+ end
+ command :estimate do |time_estimate|
if time_estimate
@updates[:time_estimate] = time_estimate
end
end
desc 'Add or substract spent time'
+ explanation do |time_spent|
+ if time_spent
+ if time_spent > 0
+ verb = 'Adds'
+ value = time_spent
+ else
+ verb = 'Substracts'
+ value = -time_spent
+ end
+
+ "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
+ end
+ end
params '<1h 30m | -1h 30m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
- command :spend do |raw_duration|
- time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
-
+ parse_params do |raw_duration|
+ Gitlab::TimeTrackingFormatter.parse(raw_duration)
+ end
+ command :spend do |time_spent|
if time_spent
@updates[:spend_time] = { duration: time_spent, user: current_user }
end
end
desc 'Remove time estimate'
+ explanation 'Removes time estimate.'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
@@ -305,6 +402,7 @@ module SlashCommands
end
desc 'Remove spent time'
+ explanation 'Removes spent time.'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
@@ -318,19 +416,28 @@ module SlashCommands
params '@user'
command :cc
- desc 'Defines target branch for MR'
+ desc 'Define target branch for MR'
+ explanation do |branch_name|
+ "Sets target branch to #{branch_name}."
+ end
params '<Local branch name>'
condition do
issuable.respond_to?(:target_branch) &&
(current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
issuable.new_record?)
end
- command :target_branch do |target_branch_param|
- branch_name = target_branch_param.strip
+ parse_params do |target_branch_param|
+ target_branch_param.strip
+ end
+ command :target_branch do |branch_name|
@updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name)
end
desc 'Move issue from one column of the board to another'
+ explanation do |target_list_name|
+ label = find_label_references(target_list_name).first
+ "Moves issue to #{label} column in the board." if label
+ end
params '~"Target column"'
condition do
issuable.is_a?(Issue) &&
@@ -352,11 +459,35 @@ module SlashCommands
end
end
+ def find_labels(labels_param)
+ extract_references(labels_param, :label) |
+ LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
+ end
+
+ def find_label_references(labels_param)
+ find_labels(labels_param).map(&:to_reference)
+ end
+
def find_label_ids(labels_param)
- label_ids_by_reference = extract_references(labels_param, :label).map(&:id)
- labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id)
+ find_labels(labels_param).map(&:id)
+ end
- label_ids_by_reference | labels_ids_by_name
+ def explain_commands(commands, opts)
+ commands.map do |name, arg|
+ definition = self.class.definition_by_name(name)
+ next unless definition
+
+ definition.explain(self, opts, arg)
+ end.compact
+ end
+
+ def extract_updates(commands, opts)
+ commands.each do |name, arg|
+ definition = self.class.definition_by_name(name)
+ next unless definition
+
+ definition.execute(self, opts, arg)
+ end
end
def extract_references(arg, type)
@@ -366,9 +497,13 @@ module SlashCommands
ext.references(type)
end
- def award_emoji_name(emoji)
- match = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern)
- match[1] if match
+ def context
+ {
+ issuable: issuable,
+ current_user: current_user,
+ project: project,
+ params: params
+ }
end
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index c9e25c7aaa2..174e7c6e95b 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -49,6 +49,44 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end
+ # Called when the assignees of an Issue is changed or removed
+ #
+ # issue - Issue object
+ # project - Project owning noteable
+ # author - User performing the change
+ # assignees - Users being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "removed all assignees"
+ #
+ # "assigned to @user1 additionally to @user2"
+ #
+ # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
+ #
+ # "assigned to @user1 and @user2"
+ #
+ # Returns the created Note object
+ def change_issue_assignees(issue, project, author, old_assignees)
+ body =
+ if issue.assignees.any? && old_assignees.any?
+ unassigned_users = old_assignees - issue.assignees
+ added_users = issue.assignees.to_a - old_assignees
+
+ text_parts = []
+ text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+
+ text_parts.join(' and ')
+ elsif old_assignees.any?
+ "removed all assignees"
+ elsif issue.assignees.any?
+ "assigned to #{issue.assignees.map(&:to_reference).to_sentence}"
+ end
+
+ create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
+ end
+
# Called when one or more labels on a Noteable are added and/or removed
#
# noteable - Noteable object
@@ -261,6 +299,23 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
+ # Called when the description of a Noteable is changed
+ #
+ # noteable - Noteable object that responds to `description`
+ # project - Project owning noteable
+ # author - User performing the change
+ #
+ # Example Note text:
+ #
+ # "changed the description"
+ #
+ # Returns the created Note object
+ def change_description(noteable, project, author)
+ body = 'changed the description'
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'description'))
+ end
+
# Called when the confidentiality changes
#
# issue - Issue object
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 8ae61694b50..322c6286365 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -251,9 +251,9 @@ class TodoService
end
def create_assignment_todo(issuable, author)
- if issuable.assignee
+ if issuable.assignees.any?
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
- create_todos(issuable.assignee, attributes)
+ create_todos(issuable.assignees, attributes)
end
end
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/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 0dc1103eece..4b6628169ef 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -394,8 +394,6 @@
%fieldset
%legend Error Reporting and Logging
- %p
- These settings require a restart to take effect.
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
@@ -403,6 +401,7 @@
= f.check_box :sentry_enabled
Enable Sentry
.help-block
+ %p This setting requires a restart to take effect.
Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here:
%a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com
@@ -411,6 +410,21 @@
.col-sm-10
= f.text_field :sentry_dsn, class: 'form-control'
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :clientside_sentry_enabled do
+ = f.check_box :clientside_sentry_enabled
+ Enable Clientside Sentry
+ .help-block
+ Sentry can also be used for reporting and logging clientside exceptions.
+ %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/
+
+ .form-group
+ = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :clientside_sentry_dsn, class: 'form-control'
+
%fieldset
%legend Repository Storage
.form-group
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 964473ee3e0..7ba3f3f6c42 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,6 +1,7 @@
.discussion-notes
%ul.notes{ data: { discussion_id: discussion.id } }
= render partial: "shared/notes/note", collection: discussion.notes, as: :note
+ .flash-container
- if current_user
.discussion-reply-holder
diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml
index 72508b91134..20b7fa471a0 100644
--- a/app/views/errors/omniauth_error.html.haml
+++ b/app/views/errors/omniauth_error.html.haml
@@ -1,16 +1,15 @@
- content_for(:title, 'Auth Error')
-%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') }
- %h1
- 422
+
.container
+ = render "shared/errors/graphic_422.svg"
%h3 Sign-in using #{@provider} auth failed
- %hr
- %p Sign-in failed because #{@error}.
- %p There are couple of steps you can take:
-%ul
- %li Try logging in using your email
- %li Try logging in using your username
- %li If you have forgotten your password, try recovering it using #{ link_to "Password recovery", new_password_path(resource_name) }
+ %p.light.subtitle Sign-in failed because #{@error}.
+
+ %p Try logging in using your username or email. If you have forgotten your password, try recovering it
-%p If none of the options work, try contacting the GitLab administrator.
+ = link_to "Sign in", new_session_path(:user), class: 'btn primary'
+ = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary'
+
+ %hr
+ %p.light If none of the options work, try contacting a GitLab administrator.
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index 8d3aa4d1a74..7c7573862d0 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -26,7 +26,7 @@
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
+ = render layout: 'projects/md_preview', locals: { url: '' } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
.clearfix
.error-alert
diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder
index 23a88448055..2ed78bb3b65 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -23,10 +23,19 @@ xml.entry do
end
end
- if issue.assignee
+ if issue.assignees.any?
+ xml.assignees do
+ issue.assignees.each do |assignee|
+ xml.assignee do
+ xml.name assignee.name
+ xml.email assignee.public_email
+ end
+ end
+ end
+
xml.assignee do
- xml.name issue.assignee.name
- xml.email issue.assignee_public_email
+ xml.name issue.assignees.first.name
+ xml.email issue.assignees.first.public_email
end
end
end
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 19473b6ab27..afcc2b6e4f3 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -28,9 +28,12 @@
= stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
+ = Gon::Base.render_data
+
= webpack_bundle_tag "runtime"
= webpack_bundle_tag "common"
= webpack_bundle_tag "main"
+ = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 36543edc040..7e011ac3e75 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,9 +1,7 @@
!!! 5
-%html{ lang: "en", class: "#{page_class}" }
+%html{ lang: I18n.locale, class: "#{page_class}" }
= render "layouts/head"
%body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } }
- = Gon::Base.render_data
-
= render "layouts/header/default", title: header_title
= render 'layouts/page', sidebar: sidebar, nav: nav
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 3368a9beb29..52fb46eb8c9 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -3,7 +3,6 @@
= render "layouts/head"
%body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } }
.page-wrap
- = Gon::Base.render_data
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index 7466423a934..ed6731bde95 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -2,7 +2,6 @@
%html{ lang: "en" }
= render "layouts/head"
%body.ui_charcoal.login-page.application.navless
- = Gon::Base.render_data
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
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/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml
new file mode 100644
index 00000000000..34bcd2a8b3a
--- /dev/null
+++ b/app/views/layouts/oauth_error.html.haml
@@ -0,0 +1,127 @@
+!!! 5
+%html{ lang: "en" }
+ %head
+ %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" }
+ %title= yield(:title)
+ :css
+ body {
+ color: #666;
+ text-align: center;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ margin: auto;
+ font-size: 16px;
+ }
+
+ .container {
+ margin: auto 20px;
+ }
+
+ h3 {
+ color: #456;
+ font-size: 22px;
+ font-weight: bold;
+ margin-bottom: 6px;
+ }
+
+ p {
+ max-width: 470px;
+ margin: 16px auto;
+ }
+
+ .subtitle {
+ margin: 0 auto 20px;
+ }
+
+ svg {
+ width: 280px;
+ height: 280px;
+ display: block;
+ margin: 40px auto;
+ }
+
+ .tv-screen path {
+ animation: move-lines 1s linear infinite;
+ }
+
+
+ @keyframes move-lines {
+ 0% {transform: translateY(0)}
+ 50% {transform: translateY(-10px)}
+ 100% {transform: translateY(-20px)}
+ }
+
+ .tv-screen path:nth-child(1) {
+ animation-delay: .2s
+ }
+
+ .tv-screen path:nth-child(2) {
+ animation-delay: .4s
+ }
+
+ .tv-screen path:nth-child(3) {
+ animation-delay: .6s
+ }
+
+ .tv-screen path:nth-child(4) {
+ animation-delay: .8s
+ }
+
+ .tv-screen path:nth-child(5) {
+ animation-delay: 2s
+ }
+
+ .text-422 {
+ animation: flicker 1s infinite;
+ }
+
+ @keyframes flicker {
+ 0% {opacity: 0.3;}
+ 10% {opacity: 1;}
+ 15% {opacity: .3;}
+ 20% {opacity: .5;}
+ 25% {opacity: 1;}
+ }
+
+ .light {
+ color: #8D8D8D;
+ }
+
+ hr {
+ max-width: 600px;
+ margin: 18px auto;
+ border: 0;
+ border-top: 1px solid #EEE;
+ }
+
+ .btn {
+ padding: 8px 14px;
+ border-radius: 3px;
+ border: 1px solid;
+ display: inline-block;
+ text-decoration: none;
+ margin: 4px 8px;
+ font-size: 14px;
+ }
+
+ .primary {
+ color: #fff;
+ background-color: #1aaa55;
+ border-color: #168f48;
+ }
+
+ .primary:hover {
+ background-color: #168f48;
+ }
+
+ .secondary {
+ color: #1aaa55;
+ background-color: #fff;
+ border-color: #1aaa55;
+ }
+
+ .secondary:hover {
+ background-color: #f3fff8;
+ }
+
+%body
+ = yield
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index f5e7ea7710d..3f5b0c54e50 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -5,14 +5,9 @@
- content_for :project_javascripts do
- project = @target_project || @project
- - if @project_wiki && @page
- - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug)
- - else
- - 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.preview_markdown_path = "#{preview_markdown_path}";
+ window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
- content_for :header_content do
.js-dropdown-menu-projects
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 02ca3ee7a28..98b75cea03f 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,3 +1,9 @@
- header_title "Snippets", snippets_path
+- content_for :page_specific_javascripts do
+ - if @snippet&.persisted? && current_user
+ :javascript
+ window.uploads_path = "#{upload_path('personal_snippet', @snippet)}";
+ window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}";
+
= render template: "layouts/application"
diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb
deleted file mode 100644
index daf20a226dd..00000000000
--- a/app/views/notify/_reassigned_issuable_email.text.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %>
-
-<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %>
-
-Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
- to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %>
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index c762578971a..eb5157ccac9 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -2,9 +2,9 @@
%p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
-- if @issue.assignee_id.present?
+- if @issue.assignees.any?
%p
- Assignee: #{@issue.assignee_name}
+ Assignee: #{@issue.assignee_list}
- if @issue.description
%div
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index ca5c2f2688c..13f1ac08e94 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -2,6 +2,6 @@ New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_name %>
+Assignee: <%= @issue.assignee_list %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
index 457e94b4800..f19ac3adfc7 100644
--- a/app/views/notify/new_mention_in_issue_email.text.erb
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -2,6 +2,6 @@ You have been mentioned in an issue.
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_name %>
+Assignee: <%= @issue.assignee_list %>
<%= @issue.description %>
diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml
index 498ba8b8365..ee2f40e1683 100644
--- a/app/views/notify/reassigned_issue_email.html.haml
+++ b/app/views/notify/reassigned_issue_email.html.haml
@@ -1 +1,10 @@
-= render 'reassigned_issuable_email', issuable: @issue
+%p
+ Assignee changed
+ - if @previous_assignees.any?
+ from
+ %strong= @previous_assignees.map(&:name).to_sentence
+ to
+ - if @issue.assignees.any?
+ %strong= @issue.assignee_list
+ - else
+ %strong Unassigned
diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb
index 710253be984..6c357f1074a 100644
--- a/app/views/notify/reassigned_issue_email.text.erb
+++ b/app/views/notify/reassigned_issue_email.text.erb
@@ -1 +1,6 @@
-<%= render 'reassigned_issuable_email', issuable: @issue %>
+Reassigned Issue <%= @issue.iid %>
+
+<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
+
+Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
+ to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml
index 2a650130f59..841df872857 100644
--- a/app/views/notify/reassigned_merge_request_email.html.haml
+++ b/app/views/notify/reassigned_merge_request_email.html.haml
@@ -1 +1,9 @@
-= render 'reassigned_issuable_email', issuable: @merge_request
+Reassigned Merge Request #{ @merge_request.iid }
+
+= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }])
+
+Assignee changed
+- if @previous_assignee
+ from #{@previous_assignee.name}
+to
+= @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index b5b4f1ff99a..998a40fefde 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -1 +1,6 @@
-<%= render 'reassigned_issuable_email', issuable: @merge_request %>
+Reassigned Merge Request <%= @merge_request.iid %>
+
+<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %>
+
+Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
+ to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %>
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/profiles/show.html.haml b/app/views/profiles/show.html.haml
index c74b3249a13..4a1438aa68e 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -73,6 +73,11 @@
= f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2"
%span.help-block This email will be displayed on your public profile.
.form-group
+ = f.label :preferred_language, class: "label-light"
+ = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
+ {}, class: "select2"
+ %span.help-block This feature is experimental and translations are not complete yet.
+ .form-group
= f.label :skype, class: "label-light"
= f.text_field :skype, class: "form-control"
.form-group
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 23e27c1105c..d0698285f84 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,3 +1,5 @@
+- referenced_users = local_assigns.fetch(:referenced_users, nil)
+
.md-area
.md-header
%ul.nav-links.clearfix
@@ -28,9 +30,10 @@
.md-write-holder
= yield
- .md.md-preview-holder.js-md-preview.hide{ class: (preview_class if defined?(preview_class)) }
+ .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } }
+ .referenced-commands.hide
- - if defined?(referenced_users) && referenced_users
+ - if referenced_users
.referenced-users.hide
%span
= icon("exclamation-triangle")
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/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml
new file mode 100644
index 00000000000..28670e7de97
--- /dev/null
+++ b/app/views/projects/blob/viewers/_balsamiq.html.haml
@@ -0,0 +1,4 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('balsamiq_viewer')
+
+.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } }
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
index 5a4eaf92b16..bc5c727bf0d 100644
--- a/app/views/projects/boards/components/_board.html.haml
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -13,8 +13,8 @@
%button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
- "aria-label" => "Add an issue",
- "title" => "Add an issue",
+ "aria-label" => "New issue",
+ "title" => "New issue",
data: { placement: "top", container: "body" } }
= icon("plus")
- if can?(current_user, :admin_list, @project)
diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml
index e75ce305440..642da679f97 100644
--- a/app/views/projects/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml
@@ -1,39 +1,28 @@
-.block.assignee
- .title.hide-collapsed
- Assignee
- - if can?(current_user, :admin_issue, @project)
- = icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
- .value.hide-collapsed
- %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" }
- No assignee
- - if can?(current_user, :admin_issue, @project)
- \-
- %a.js-assign-yourself{ href: "#" }
- assign yourself
- %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username",
- "v-if" => "issue.assignee" }
- %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar",
- width: "32", alt: "Avatar" }
- %span.author
- {{ issue.assignee.name }}
- %span.username
- = precede "@" do
- {{ issue.assignee.username }}
+.block.assignee{ ref: "assigneeBlock" }
+ %template{ "v-if" => "issue.assignees" }
+ %assignee-title{ ":number-of-assignees" => "issue.assignees.length",
+ ":loading" => "loadingAssignees",
+ ":editable" => can?(current_user, :admin_issue, @project) }
+ %assignees.value{ "root-path" => "#{root_url}",
+ ":users" => "issue.assignees",
+ ":editable" => can?(current_user, :admin_issue, @project),
+ "@assign-self" => "assignSelf" }
+
- if can?(current_user, :admin_issue, @project)
.selectbox.hide-collapsed
%input{ type: "hidden",
- name: "issue[assignee_id]",
- id: "issue_assignee_id",
- ":value" => "issue.assignee.id",
- "v-if" => "issue.assignee" }
+ name: "issue[assignee_ids][]",
+ ":value" => "assignee.id",
+ "v-if" => "issue.assignees",
+ "v-for" => "assignee in issue.assignees" }
.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-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } },
":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")
- .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
+ .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
= dropdown_content
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index d3c3e40d518..796ecdfd014 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -1,4 +1,5 @@
- page_title "New Branch"
+- default_ref = params[:ref] || @project.default_branch
- if @error
.alert.alert-danger
@@ -16,12 +17,11 @@
.help-block.text-danger.js-branch-name-error
.form-group
= label_tag :ref, 'Create from', class: 'control-label'
- .col-sm-10
- = hidden_field_tag :ref, params[:ref] || @project.default_branch
- = dropdown_tag(params[:ref] || @project.default_branch,
- options: { toggle_class: 'js-branch-select wide',
- filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches",
- data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } })
+ .col-sm-10.dropdown.create-from
+ = hidden_field_tag :ref, default_ref
+ = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
+ .text-left.dropdown-toggle-text= default_ref
+ = render 'shared/ref_dropdown', dropdown_class: 'wide'
.help-block Existing branch name, tag, or commit SHA
.form-actions
= button_tag 'Create branch', class: 'btn btn-create', tabindex: 3
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..6051ea2f1ce 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"
@@ -11,7 +13,7 @@
.block-connector
= render "projects/diffs/diffs", diffs: @diffs, environment: @environment
- = render "projects/notes/notes_with_form"
+ = render "shared/notes/notes_with_form"
- if can_collaborate_with_project?
- %w(revert cherry-pick).each do |type|
= render "projects/commit/change", type: type, commit: @commit, title: @commit.title
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index 0f080b6acee..1f4c9fac54c 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -9,7 +9,7 @@
= hidden_field_tag :from, params[:from]
= button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
.dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag'
- = render "ref_dropdown"
+ = render 'shared/ref_dropdown'
.compare-ellipsis.inline ...
.form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.input-group.inline-input-group
@@ -17,7 +17,7 @@
= hidden_field_tag :to, params[:to]
= button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
.dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag'
- = render "ref_dropdown"
+ = render 'shared/ref_dropdown'
&nbsp;
= button_tag "Compare", class: "btn btn-create commits-compare-btn"
- if @merge_request.present?
diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml
index c3f95860e92..cdad0bc7231 100644
--- a/app/views/projects/cycle_analytics/_empty_stage.html.haml
+++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml
@@ -2,6 +2,6 @@
.empty-stage
.icon-no-data
= custom_icon ('icon_no_data')
- %h4 We don't have enough data to show this stage.
+ %h4 {{ __('We don\'t have enough data to show this stage.') }}
%p
{{currentStage.emptyStageText}}
diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml
index 0ffc79b3181..c3eda398234 100644
--- a/app/views/projects/cycle_analytics/_no_access.html.haml
+++ b/app/views/projects/cycle_analytics/_no_access.html.haml
@@ -2,6 +2,6 @@
.no-access-stage
.icon-lock
= custom_icon ('icon_lock')
- %h4 You need permission.
+ %h4 {{ __('You need permission.') }}
%p
- Want to see the data? Please ask administrator for access.
+ {{ __('Want to see the data? Please ask an administrator for access.') }}
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index dd3fa814716..b158a81471c 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -2,29 +2,30 @@
- page_title "Cycle Analytics"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('locale')
= page_specific_javascript_bundle_tag('cycle_analytics')
= render "projects/head"
#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
- .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" }
- = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()")
- .row
- .col-sm-3.col-xs-12.svg-container
- = custom_icon('icon_cycle_analytics_splash')
- .col-sm-8.col-xs-12.inner-content
- %h4
- Introducing Cycle Analytics
- %p
- Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
-
- = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
+ .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" }
+ %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box' }
+ = icon("times", "@click" => "dismissOverviewDialog()")
+ .svg-container
+ = custom_icon('icon_cycle_analytics_splash')
+ .inner-content
+ %h4
+ {{ __('Introducing Cycle Analytics') }}
+ %p
+ {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }}
+ %p
+ = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
= icon("spinner spin", "v-show" => "isLoading")
.wrapper{ "v-show" => "!isLoading && !hasError" }
.panel.panel-default
.panel-heading
- Pipeline Health
+ {{ __('Pipeline Health') }}
.content-block
.container-fluid
.row
@@ -34,15 +35,15 @@
.col-sm-3.col-xs-12.column
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
- %span.dropdown-label Last 30 days
+ %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
%a{ "href" => "#", "data-value" => "30" }
- Last 30 days
+ {{ n__('Last %d day', 'Last %d days', 30) }}
%li
%a{ "href" => "#", "data-value" => "90" }
- Last 90 days
+ {{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container
.panel.panel-default.stage-panel
.panel-heading
@@ -50,20 +51,20 @@
%ul
%li.stage-header
%span.stage-name
- Stage
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" }
+ {{ __('ProjectLifecycle|Stage') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" }
%li.median-header
%span.stage-name
- Median
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" }
+ {{ __('Median') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" }
%li.event-header
%span.stage-name
- {{ currentStage ? currentStage.legend : 'Related Issues' }}
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" }
+ {{ currentStage ? __(currentStage.legend) : __('Related Issues') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
%li.total-time-header
%span.stage-name
- Total Time
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" }
+ {{ __('Total Time') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" }
.stage-panel-body
%nav.stage-nav
%ul
@@ -75,10 +76,10 @@
%span{ "v-if" => "stage.value" }
{{ stage.value }}
%span.stage-empty{ "v-else" => true }
- Not enough data
+ {{ __('Not enough data') }}
%template{ "v-else" => true }
%span.not-available
- Not available
+ {{ __('Not available') }}
.section.stage-events
%template{ "v-if" => "isLoadingStage" }
= icon("spinner spin")
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/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/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml
index b6116dbec41..debb0214d06 100644
--- a/app/views/projects/group_links/_index.html.haml
+++ b/app/views/projects/group_links/_index.html.haml
@@ -6,11 +6,9 @@
%p
Projects can be stored in only one group at once. However you can share a project with other groups here.
.col-lg-9
- %h5.prepend-top-0
- Set a group to share
= form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do
.form-group
- = label_tag :link_group_id, "Group", class: "label-light"
+ = label_tag :link_group_id, "Select a group to share with", class: "label-light"
= groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true)
.form-group
= label_tag :link_group_access, "Max access level", class: "label-light"
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 5d4e593e4ef..4dfda54feb5 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -4,4 +4,4 @@
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
#notes
- = render 'projects/notes/notes_with_form'
+ = render 'shared/notes/notes_with_form'
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 0e3902c066a..c184e0e0022 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -13,9 +13,9 @@
%li
CLOSED
- - if issue.assignee
+ - if issue.assignees.any?
%li
- = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name")
+ = render 'shared/issuable/assignees', project: @project, issue: issue
= render 'shared/issuable_meta_data', issuable: issue
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..9084883eb3e 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -51,16 +51,11 @@
.issue-details.issuable-details
.detail-page-description.content-block
- .issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title),
- "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
+ .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
+ "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '',
} }
.issue-title-entrypoint
- - if @issue.description.present?
- .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
- .wiki
- = markdown_field(@issue, :description)
- %textarea.hidden.js-task-list-field
- = @issue.description
+
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago')
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
@@ -70,8 +65,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/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index 15b5a51c1d0..2e6420db212 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -8,4 +8,4 @@
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
{{ buttonText }}
-#notes= render "projects/notes/notes_with_form"
+#notes= render "shared/notes/notes_with_form"
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 881ee9fd596..9e306d4543c 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -6,7 +6,7 @@
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('diff_notes')
-.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) }
+.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) }
= render "projects/merge_requests/show/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
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/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 0f4a8508751..9a95b2a82ff 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -9,9 +9,9 @@
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 718b52dd82e..d70ec8a6062 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -31,14 +31,14 @@
- 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
+ = 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' do
+ = 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' do
+ = 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
deleted file mode 100644
index f1e251d65b7..00000000000
--- a/app/views/projects/notes/_edit.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-.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/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/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index d7cefb8613e..1aa48bf9813 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,3 +1,5 @@
+- failed_builds = @pipeline.statuses.latest.failed
+
.tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom
%li.js-pipeline-tab-link
@@ -7,8 +9,11 @@
= link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
Jobs
%span.badge.js-builds-counter= pipeline.statuses.count
-
-
+ - if failed_builds.present?
+ %li.js-failures-tab-link
+ = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
+ Failed Jobs
+ %span.badge.js-failures-counter= failed_builds.count
.tab-content
#js-tab-pipeline.tab-pane
@@ -39,3 +44,13 @@
%th Coverage
%th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
+ - if failed_builds.present?
+ #js-tab-failures.build-failures.tab-pane
+ - failed_builds.each_with_index do |build, index|
+ .build-state
+ %span.ci-status-icon-failed= custom_icon('icon_status_failed')
+ %span.stage
+ = build.stage.titleize
+ %span.build-name
+ = link_to build.name, pipeline_build_url(pipeline, build)
+ %pre.build-log= build_summary(build, skip: index >= 10)
diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml
index ab0771b5751..d080b6c83d4 100644
--- a/app/views/projects/project_members/_index.html.haml
+++ b/app/views/projects/project_members/_index.html.haml
@@ -6,13 +6,19 @@
%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)
= render "projects/project_members/new_project_member"
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
- .append-bottom-default.clearfix
+ .clearfix
%h5.member.existing-title
Existing members and groups
- if @group_links.any?
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_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index b8e885b4d9a..99bc2516366 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -25,7 +25,7 @@
.merge_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-merge wide',
- dropdown_class: 'dropdown-menu-selectable',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
.form-group
%label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
@@ -34,7 +34,7 @@
.push_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-push wide',
- dropdown_class: 'dropdown-menu-selectable',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
.panel-footer
diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml
index d6044aacaec..c61b2951e1e 100644
--- a/app/views/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml
@@ -1,10 +1,10 @@
%td
= hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level
= dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container',
+ options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }})
%td
= hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level
= dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container',
+ options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }})
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/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
index 74851519077..c50515cfe06 100644
--- a/app/views/projects/protected_tags/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/_dropdown.html.haml
@@ -2,7 +2,7 @@
= dropdown_tag('Select tag or create wildcard',
options: { toggle_class: 'js-protected-tag-select js-filter-submit wide',
- filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag",
+ filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_tag_name],
diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml
index 62823bee46e..cc80bd04dd0 100644
--- a/app/views/projects/protected_tags/_update_protected_tag.haml
+++ b/app/views/projects/protected_tags/_update_protected_tag.haml
@@ -1,5 +1,5 @@
%td
= hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level
= dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container',
+ options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container',
data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }})
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml
index 79d8d721aa9..93ee9382a6e 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/releases/edit.html.haml
@@ -11,9 +11,9 @@
= form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.error-alert
.prepend-top-default
= f.submit 'Save changes', class: 'btn btn-save'
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
index 5a5ade03624..8c7f9e0191e 100644
--- a/app/views/projects/settings/_head.html.haml
+++ b/app/views/projects/settings/_head.html.haml
@@ -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/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/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 7a175f63eeb..aab1c043e66 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -9,4 +9,4 @@
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
- #notes= render "projects/notes/notes_with_form"
+ #notes= render "shared/notes/notes_with_form"
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/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 160d4c7a223..7c607d2956b 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -28,9 +28,9 @@
.form-group
= label_tag :release_description, 'Release notes', class: 'control-label'
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.
.form-actions
= button_tag 'Create tag', class: 'btn btn-create', tabindex: 3
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/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index 0d2cd4a7476..6cb7c1e9c4d 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -12,9 +12,9 @@
.form-group
= f.label :content, class: 'control-label'
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
+ = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do
= render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...'
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.clearfix
.error-alert
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/projects/compare/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml
index 05fb37cdc0f..96f68c80c48 100644
--- a/app/views/projects/compare/_ref_dropdown.html.haml
+++ b/app/views/shared/_ref_dropdown.html.haml
@@ -1,4 +1,6 @@
-.dropdown-menu.dropdown-menu-selectable
+- dropdown_class = local_assigns.fetch(:dropdown_class, '')
+
+.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class }
= dropdown_title "Select Git revision"
= dropdown_filter "Filter by Git revision"
= dropdown_content
diff --git a/app/views/shared/errors/_graphic_422.svg b/app/views/shared/errors/_graphic_422.svg
new file mode 100644
index 00000000000..87128ecd69d
--- /dev/null
+++ b/app/views/shared/errors/_graphic_422.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 246" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="178" height="136" rx="10"/><mask id="1" width="178" height="136" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" fill-rule="nonzero"><path d="m109.88 37.634c5.587-3.567 12.225-5.634 19.345-5.634 7.445 0 14.363 2.26 20.1 6.132l21.435-37.13c.554-.959 1.771-1.292 2.734-.736.957.552 1.284 1.777.73 2.736l-21.496 37.23c-.065.112-.138.215-.219.309 3.686 3.13 6.733 6.988 8.919 11.353l-3.393.002c-5.775-10.322-16.705-16.901-28.814-16.901-12.12 0-23.06 6.594-28.833 16.935l-3.393.002c2.32-4.646 5.616-8.72 9.618-11.954l-21.349-36.977c-.554-.959-.227-2.184.73-2.736.963-.556 2.181-.223 2.734.736l21.15 36.629"/><path d="m3 70v134c0 9.389 7.611 17 16.997 17h220.01c9.389 0 16.997-7.611 16.997-17v-134c0-9.389-7.611-17-16.997-17h-220.01c-9.389 0-16.997 7.611-16.997 17m-3 0c0-11.05 8.95-20 19.997-20h220.01c11.04 0 19.997 8.958 19.997 20v134c0 11.05-8.95 20-19.997 20h-220.01c-11.04 0-19.997-8.958-19.997-20v-134"/></g><ellipse cx="129" cy="241.5" fill="#f9f9f9" rx="89" ry="4.5"/><g fill-rule="nonzero" transform="translate(210 70)"><path fill="#eaeaea" d="m16 29c7.18 0 13-5.82 13-13 0-7.18-5.82-13-13-13-7.18 0-13 5.82-13 13 0 7.18 5.82 13 13 13m0 3c-8.837 0-16-7.163-16-16 0-8.837 7.163-16 16-16 8.837 0 16 7.163 16 16 0 8.837-7.163 16-16 16" id="2"/><path fill="#6b4fbb" d="m16 21c2.761 0 5-2.239 5-5 0-2.761-2.239-5-5-5-2.761 0-5 2.239-5 5 0 2.761 2.239 5 5 5m0 3c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8" id="3"/></g><g fill-rule="nonzero" transform="translate(210 109)"><use xlink:href="#2"/><use xlink:href="#3"/></g><g transform="translate(210 147)"><path fill="#e5e5e5" fill-rule="nonzero" d="m3 5.992v45.02c0 1.647 1.346 2.992 3 2.992h20c1.657 0 3-1.341 3-2.992v-45.02c0-1.647-1.346-2.992-3-2.992h-20c-1.657 0-3 1.341-3 2.992m-3 0c0-3.309 2.687-5.992 6-5.992h20c3.314 0 6 2.692 6 5.992v45.02c0 3.309-2.687 5.992-6 5.992h-20c-3.314 0-6-2.692-6-5.992v-45.02"/><rect width="16" height="4" x="8" y="27" fill="#fdb692" rx="2"/><rect width="16" height="4" x="8" y="19" fill="#fc9867" rx="2"/><rect width="16" height="4" x="8" y="11" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="8" y="35" fill="#fed3bd" rx="2"/><rect width="16" height="4" x="8" y="43" fill="#fef0e9" rx="2"/></g><g transform="translate(16 69)"><use fill="#6b4fbb" fill-opacity=".1" stroke="#e5e5e5" stroke-width="6" mask="url(#1)" xlink:href="#0"/><g class="tv-screen" fill="#fff"><path opacity=".4" mix-blend-mode="overlay" d="m3 17h172v16h-172z"/><path opacity=".6" mix-blend-mode="overlay" d="m3 70h172v24h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 107h172v16h-172z"/><path opacity=".4" mix-blend-mode="overlay" d="m3 40h172v8h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 55h172v8h-172z"/></g></g><path class="text-422" d="m.693 19h5.808c.277 0 .498-.224.498-.5 0-.268-.223-.5-.498-.5h-5.808v-2.094l3.777-5.906h3.916l-4.124 6.454h6.259v-6.454h.978c.273 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-.978v-2h4.698v6h-2.721c-.277 0-.498.224-.498.5 0 .268.223.5.498.5h2.721v2.454h2.723v4.2h-2.723v5.346h-4.698v-5.346h-9.828v-1.654m4.417-10l1.279-2h3.914l-1.278 2h-3.916m1.919-3l1.279-2h4.192c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.552l1.142-1.786h5.13v4.786h-8.191m31.09 19v1h-15.738v-2h5.118c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-5.118v-1.184l2.656-2.822c.682-.725 1.306-1.39 1.872-1.994h5.428c-.389.394-.808.815-1.256 1.264-1.428 1.428-2.562 2.568-3.403 3.42h10.442v2.316h-4.614c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.614m-6.674-13c.493-.631.87-1.208 1.129-1.73.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.589c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.589v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-2.782c-.27 0-.5.224-.5.5 0 .268.224.5.5.5h3.602c.654 1.01.981 2.209.981 3.605 0 .974-.163 1.887-.49 2.739-.326.852-.888 1.798-1.685 2.839-.397.509-1.261 1.448-2.594 2.816h-5.474c1.34-1.436 2.261-2.436 2.763-3h4.396c.271 0 .499-.224.499-.5 0-.268-.223-.5-.499-.5h-3.557m28.14 12v2h-15.738v-4.184l2.651-2.816h5.313c-1.087 1.089-1.976 1.983-2.668 2.684h10.442v1.316h-4.083c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.083m-2.069-11c-.045.061-.092.122-.139.184-.567.727-2.089 2.333-4.568 4.816h-5.372c2.601-2.77 4.204-4.503 4.81-5.198.83-.952 1.428-1.796 1.793-2.532.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.117c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-3.117v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-1.248c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.069c.654 1.01.981 2.209.981 3.605 0 .844-.123 1.642-.368 2.395h-2.683c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.272c-.159.321-.347.655-.566 1h-3.706c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h3.01" transform="translate(75 124)" fill="#5c5c5c"/></g></svg>
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
new file mode 100644
index 00000000000..36bbb1148d4
--- /dev/null
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -0,0 +1,15 @@
+- max_render = 3
+- max = [max_render, issue.assignees.length].min
+
+- issue.assignees.each_with_index do |assignee, index|
+ - if index < max
+ = link_to_member(@project, assignee, name: false, title: "Assigned to :name")
+
+- if issue.assignees.length > max_render
+ - counter = issue.assignees.length - max_render
+
+ %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } }
+ - if counter < 99
+ = "+#{counter}"
+ - else
+ 99+
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 17107f55a2d..7748351b333 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -17,7 +17,7 @@
= render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
-= render 'shared/issuable/form/description', issuable: issuable, form: form
+= render 'shared/issuable/form/description', issuable: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
.form-group
diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml
index 171da899937..db407363a09 100644
--- a/app/views/shared/issuable/_participants.html.haml
+++ b/app/views/shared/issuable/_participants.html.haml
@@ -12,9 +12,9 @@
- participants.each do |participant|
.participants-author.js-participants-author
= link_to_member(@project, participant, name: false, size: 24)
- - if participants_extra > 0
- .participants-more
- %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
- + #{participants_extra} more
+ - if participants_extra > 0
+ .hide-collapsed.participants-more
+ %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
+ + #{participants_extra} more
:javascript
IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row};
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index f1350169bbe..f7b87171573 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -117,21 +117,26 @@
.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
%li
%a{ href: "#", data: { id: "close" } } Closed
.filter-item.inline
+ - if type == :issues
+ - field_name = "update[assignee_ids][]"
+ - else
+ - field_name = "update[assignee_id]"
+
= 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: field_name } })
.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..44e624c15a7 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,10 +1,10 @@
- todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('issuable')
+ = page_specific_javascript_bundle_tag('sidebar')
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
- .issuable-sidebar
+ .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
- if current_user
@@ -20,36 +20,55 @@
.block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
.block.assignee
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
- - if issuable.assignee
- = link_to_member(@project, issuable.assignee, size: 24)
- - else
- = icon('user', 'aria-hidden': 'true')
- .title.hide-collapsed
- Assignee
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
- .value.hide-collapsed
- - if issuable.assignee
- = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
- - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee)
- %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
- = icon('exclamation-triangle', 'aria-hidden': 'true')
- %span.username
- = issuable.assignee.to_reference
- - else
- %span.assign-yourself.no-value
- No assignee
- - if can_edit_issuable
- \-
- %a.js-assign-yourself{ href: '#' }
- assign yourself
+ - if issuable.instance_of?(Issue)
+ #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } }
+ - else
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
+ - if issuable.assignee
+ = link_to_member(@project, issuable.assignee, size: 24)
+ - else
+ = icon('user', 'aria-hidden': 'true')
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to 'Edit', '#', class: 'edit-link pull-right'
+ .value.hide-collapsed
+ - if issuable.assignee
+ = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
+ - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee)
+ %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
+ = icon('exclamation-triangle', 'aria-hidden': 'true')
+ %span.username
+ = issuable.assignee.to_reference
+ - else
+ %span.assign-yourself.no-value
+ No assignee
+ - if can_edit_issuable
+ \-
+ %a.js-assign-yourself{ href: '#' }
+ assign yourself
.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 } })
+ - issuable.assignees.each do |assignee|
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil
+ - 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_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }
+
+ - if issuable.instance_of?(Issue)
+ - if issuable.assignees.length == 0
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil
+ - title = 'Select assignee'
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]"
+ - options[:data][:multi_select] = true
+ - options[:data]['dropdown-title'] = title
+ - options[:data]['dropdown-header'] = 'Assignee'
+ - options[:data]['max-select'] = 1
+ - else
+ - title = 'Select assignee'
+
+ = dropdown_tag(title, options: options)
.block.milestone
.sidebar-collapsed-icon
= icon('clock-o', 'aria-hidden': 'true')
@@ -75,11 +94,10 @@
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
- if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block
- %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') }
- // Fallback while content is loading
- .title.hide-collapsed
- Time tracking
- = icon('spinner spin', 'aria-hidden': 'true')
+ // Fallback while content is loading
+ .title.hide-collapsed
+ Time tracking
+ = icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
@@ -136,7 +154,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')
@@ -169,8 +187,13 @@
= clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript
- gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
- new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}");
+ gl.sidebarOptions = {
+ endpoint: "#{issuable_json_path(issuable)}",
+ editable: #{can_edit_issuable ? true : false},
+ currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)},
+ rootPath: "#{root_path}"
+ };
+
new MilestoneSelect('{"full_path":"#{@project.full_path}"}');
new LabelsSelect();
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
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/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml
index dbace9ce401..7ef0ae96be2 100644
--- a/app/views/shared/issuable/form/_description.html.haml
+++ b/app/views/shared/issuable/form/_description.html.haml
@@ -1,15 +1,22 @@
+- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
- form = local_assigns.fetch(:form)
+- supports_slash_commands = issuable.new_record?
+
+- if supports_slash_commands
+ - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name)
+- else
+ - preview_url = preview_markdown_path(project)
.form-group.detail-page-description
= form.label :description, 'Description', class: 'control-label'
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
+ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description,
classes: 'note-textarea',
placeholder: "Write a comment or drag your files here...",
- supports_slash_commands: !issuable.persisted?
- = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
+ supports_slash_commands: supports_slash_commands
+ = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands
.clearfix
.error-alert
diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml
new file mode 100644
index 00000000000..c33474ac3b4
--- /dev/null
+++ b/app/views/shared/issuable/form/_issue_assignee.html.haml
@@ -0,0 +1,30 @@
+- issue = issuable
+.block.assignee
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) }
+ - if issue.assignees.any?
+ - issue.assignees.each do |assignee|
+ = link_to_member(@project, assignee, size: 24)
+ - else
+ = icon('user', 'aria-hidden': 'true')
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to 'Edit', '#', class: 'edit-link pull-right'
+ .value.hide-collapsed
+ - if issue.assignees.any?
+ - issue.assignees.each do |assignee|
+ = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do
+ %span.username
+ = assignee.to_reference
+ - else
+ %span.assign-yourself.no-value
+ No assignee
+ - if can_edit_issuable
+ \-
+ %a.js-assign-yourself{ href: '#' }
+ assign yourself
+
+ .selectbox.hide-collapsed
+ = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids'
+ = 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 } })
diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
new file mode 100644
index 00000000000..18011d528a0
--- /dev/null
+++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
@@ -0,0 +1,31 @@
+- merge_request = issuable
+.block.assignee
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) }
+ - if merge_request.assignee
+ = link_to_member(@project, merge_request.assignee, size: 24)
+ - else
+ = icon('user', 'aria-hidden': 'true')
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to 'Edit', '#', class: 'edit-link pull-right'
+ .value.hide-collapsed
+ - if merge_request.assignee
+ = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
+ - unless merge_request.can_be_merged_by?(merge_request.assignee)
+ %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
+ = icon('exclamation-triangle', 'aria-hidden': 'true')
+ %span.username
+ = merge_request.assignee.to_reference
+ - else
+ %span.assign-yourself.no-value
+ No assignee
+ - if can_edit_issuable
+ \-
+ %a.js-assign-yourself{ href: '#' }
+ assign yourself
+
+ .selectbox.hide-collapsed
+ = f.hidden_field 'assignee_id', value: merge_request.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, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } })
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 9dbfedb84f1..9281a515744 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -10,13 +10,27 @@
.row
%div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
.form-group.issue-assignee
- = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
- .col-sm-10{ class: ("col-lg-8" if has_due_date) }
- .issuable-form-select-holder
- = form.hidden_field :assignee_id
- = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
- placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
+ - if issuable.is_a?(Issue)
+ = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder.selectbox
+ - issuable.assignees.each do |assignee|
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name }
+
+ - if issuable.assignees.length === 0
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
+
+ = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false))
+ = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
+ - else
+ = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
+ .col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder
+ = form.hidden_field :assignee_id
+
+ = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
+ placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
+ = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
.form-group.issue-milestone
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 10050adfda5..92f6e7428ae 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -1,5 +1,5 @@
- if requesters.any?
- .panel.panel-default
+ .panel.panel-default.prepend-top-default
.panel-heading
Users requesting access to
%strong= membership_source.name
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index 5247d6a51e6..22547a30cdf 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -1,7 +1,7 @@
-# @project is present when viewing Project's milestone
- project = @project || issuable.project
- namespace = @project_namespace || project.namespace.becomes(Namespace)
-- assignee = issuable.assignee
+- assignees = issuable.assignees
- issuable_type = issuable.class.table_name
- base_url_args = [namespace, project]
- issuable_type_args = base_url_args + [issuable_type]
@@ -26,7 +26,7 @@
- render_colored_label(label)
%span.assignee-icon
- - if assignee
- = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }),
+ - assignees.each do |assignee|
+ = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do
- - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '')
+ - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '')
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 33f93dccd3c..a26b3b8009e 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -2,7 +2,7 @@
- labels.each do |label|
- options = { milestone_title: @milestone.title, label_name: label.title }
- %li
+ %li.is-not-draggable
%span.label-row
%span.label-name
= link_to milestones_label_path(options) do
@@ -10,10 +10,8 @@
%span.prepend-description-left
= markdown_field(label, :description)
- .pull-info-right
- %span.append-right-20
- = link_to milestones_label_path(options.merge(state: 'opened')) do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
- %span.append-right-20
- = link_to milestones_label_path(options.merge(state: 'closed')) do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
+ .pull-right.hidden-xs.hidden-sm.hidden-md
+ = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
+ - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
+ = link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
+ - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
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/projects/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 29cf5825292..29cf5825292 100644
--- a/app/views/projects/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml
new file mode 100644
index 00000000000..4a020865828
--- /dev/null
+++ b/app/views/shared/notes/_edit.html.haml
@@ -0,0 +1,3 @@
+.original-note-content.hidden{ data: { post_url: note_url(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: note_url(note) } }= note.note
diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index 8b4e5928e0d..8923e5602a4 100644
--- a/app/views/projects/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -2,13 +2,13 @@
= form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do
= hidden_field_tag :target_id, '', class: 'js-form-target-id'
= hidden_field_tag :target_type, '', class: 'js-form-target-type'
- = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview', referenced_users: true } do
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
= render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..."
- = render 'projects/notes/hints'
+ = render 'shared/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'
+ = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-save-button'
%button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
Cancel
diff --git a/app/views/projects/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 0d835a9e949..eaf50bc2115 100644
--- a/app/views/projects/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -1,6 +1,10 @@
- supports_slash_commands = note_supports_slash_commands?(@note)
+- if supports_slash_commands
+ - preview_url = preview_markdown_path(@project, slash_commands_target_type: @note.noteable_type, slash_commands_target_id: @note.noteable_id)
+- else
+ - preview_url = preview_markdown_path(@project)
-= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
+= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
@@ -18,17 +22,17 @@
-# DiffNote
= f.hidden_field :position
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
+ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: f,
attr: :note,
classes: 'note-textarea js-note-text',
placeholder: "Write a comment or drag your files here...",
supports_slash_commands: supports_slash_commands
- = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands
+ = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands
.error-alert
.note-form-actions.clearfix
- = render partial: 'projects/notes/comment_button'
+ = render partial: 'shared/notes/comment_button'
= yield(:note_actions)
diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 81d97eabe65..81d97eabe65 100644
--- a/app/views/projects/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 731270d4127..5c1156b06fb 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -2,7 +2,11 @@
- 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} }
+%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
@@ -36,12 +40,9 @@
.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)
+ = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
- if note_editable
- - if note.for_personal_snippet?
- = render 'snippets/notes/edit', note: note
- - else
- = render 'projects/notes/edit', note: note
+ = render 'shared/notes/edit', note: note
.note-awards
= render 'award_emoji/awards_block', awardable: note, inline: false
- if note.system
diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 555228623cc..9930cbd96d7 100644
--- a/app/views/projects/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -1,18 +1,18 @@
%ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes"
-= render 'projects/notes/edit_form'
+= render 'shared/notes/edit_form', project: @project
%ul.notes.notes-form.timeline
%li.timeline-entry
.flash-container.timeline-content
- - if can? current_user, :create_note, @project
+ - if can_create_note?
.timeline-icon.hidden-xs.hidden-sm
%a.author_link{ href: user_path(current_user) }
= image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40'
.timeline-content.timeline-content-form
- = render "projects/notes/form", view: diff_view
+ = render "shared/notes/form", view: diff_view
- elsif !current_user
.disabled-comment.text-center
.disabled-comment-text.inline
@@ -23,4 +23,4 @@
to post a comment
:javascript
- var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}")
+ var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}")
diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml
index 9bcb4544b97..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
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index d084f5e9684..501c09d71d5 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -21,4 +21,4 @@
= markdown_field(@snippet, :title)
- if @snippet.updated_at != @snippet.created_at
- = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago')
+ = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index dace11e5474..679a5e934da 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -1,13 +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}", data: { position: 'right' } do
+ = 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' do
+ = 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' do
+ = 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
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/views/snippets/notes/_edit.html.haml
+++ /dev/null
diff --git a/app/views/snippets/notes/_notes.html.haml b/app/views/snippets/notes/_notes.html.haml
deleted file mode 100644
index f07d6b8c126..00000000000
--- a/app/views/snippets/notes/_notes.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-%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 98287cba5b4..51dbbc32cc9 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -2,11 +2,11 @@
= render 'shared/snippets/header'
-%article.file-holder.snippet-file-content
- = render 'shared/snippets/blob'
+.personal-snippets
+ %article.file-holder.snippet-file-content
+ = render 'shared/snippets/blob'
-.row-content-block.top-block.content-component-block
- = render 'award_emoji/awards_block', awardable: @snippet, inline: true
+ .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'
+ #notes= render "shared/notes/notes_with_form"
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
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 015a41b6e82..127d8dfbb61 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -2,27 +2,24 @@ class PostReceive
include Sidekiq::Worker
include DedicatedSidekiqQueue
- def perform(repo_path, identifier, changes)
- repo_relative_path = Gitlab::RepoPath.strip_storage_path(repo_path)
+ def perform(project_identifier, identifier, changes)
+ project, is_wiki = parse_project_identifier(project_identifier)
+
+ if project.nil?
+ log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"")
+ return false
+ end
changes = Base64.decode64(changes) unless changes.include?(' ')
# Use Sidekiq.logger so arguments can be correlated with execution
# time and thread ID's.
Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS']
- post_received = Gitlab::GitPostReceive.new(repo_relative_path, identifier, changes)
-
- if post_received.project.nil?
- log("Triggered hook for non-existing project with full path \"#{repo_relative_path}\"")
- return false
- end
+ post_received = Gitlab::GitPostReceive.new(project, identifier, changes)
- if post_received.wiki?
+ if is_wiki
# Nothing defined here yet.
- elsif post_received.regular_project?
- process_project_changes(post_received)
else
- log("Triggered hook for unidentifiable repository type with full path \"#{repo_relative_path}\"")
- false
+ process_project_changes(post_received)
end
end
@@ -47,6 +44,21 @@ class PostReceive
private
+ # To maintain backwards compatibility, we accept both gl_repository or
+ # repository paths as project identifiers. Our plan is to migrate to
+ # gl_repository only with the following plan:
+ # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths
+ # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present
+ # 9.4 (or patch release): Make GitLab Shell always pass gl_repository
+ # 9.5 (or patch release): Handle only gl_repository as project identifier on this method
+ def parse_project_identifier(project_identifier)
+ if project_identifier.start_with?('/')
+ Gitlab::RepoPath.parse(project_identifier)
+ else
+ Gitlab::GlRepository.parse(project_identifier)
+ end
+ end
+
def log(message)
Gitlab::GitLogger.error("POST-RECEIVE: #{message}")
end
diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb
new file mode 100644
index 00000000000..5ce0e0405d0
--- /dev/null
+++ b/app/workers/propagate_service_template_worker.rb
@@ -0,0 +1,21 @@
+# Worker for updating any project specific caches.
+class PropagateServiceTemplateWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ LEASE_TIMEOUT = 4.hours.to_i
+
+ def perform(template_id)
+ return unless try_obtain_lease_for(template_id)
+
+ Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id))
+ end
+
+ private
+
+ def try_obtain_lease_for(template_id)
+ Gitlab::ExclusiveLease.
+ new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT).
+ try_obtain
+ end
+end